From ad570ca0b519ce1edbfb5eabe9f57e9ffef2d25e Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Thu, 2 Jul 2026 10:00:22 +1200 Subject: [PATCH 1/2] Premium Analytics: add post/page detail traffic view (WOOA7S-1622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-resource detail page for an individual post or page: a /post/$postId route with its own header (Stats breadcrumb + summary card), three tabs (Post traffic / Email opens / Email clicks), the shared date-range and comparison picker, and a customizable per-tab widget grid — mirroring the dashboard while working independently and scoped to one resource. - Extract the dashboard's date-filter controller into a shared useReportDateFilters hook in the routing package; the dashboard consumes it too instead of duplicating the staged-search logic. - Resolve WidgetRoot report params from the current matched route so widgets on any route (not only the dashboard at /) receive the date range and the post_id scope; seed post_id into the URL in the route guard. - Header reads title/type/published date from the typed Stats post payload. Builds on the data-layer PR (StatsPostMeta typing + post_id report param). --- .../add-WOOA7S-1622-post-detail-page | 4 + .../packages/routing/package.json | 6 +- .../packages/routing/src/hooks/index.ts | 1 + .../hooks/use-report-date-filters/index.ts | 1 + .../use-report-date-filters.tsx | 212 ++++++++++++++++++ .../packages/routing/src/index.ts | 2 +- .../src/components/widget-root/README.md | 2 +- .../components/widget-root/widget-root.tsx | 25 ++- .../routes/dashboard/stage.tsx | 197 +--------------- .../routes/post-detail/components/index.ts | 3 + .../post-detail-tabs.module.scss | 15 ++ .../post-detail-tabs/post-detail-tabs.tsx | 63 ++++++ .../post-summary-card.module.scss | 38 ++++ .../post-summary-card/post-summary-card.tsx | 70 ++++++ .../stats-breadcrumbs.module.scss | 18 ++ .../stats-breadcrumbs/stats-breadcrumbs.tsx | 47 ++++ .../routes/post-detail/config/index.ts | 11 + .../routes/post-detail/config/tab-layouts.ts | 23 ++ .../routes/post-detail/config/tabs.test.ts | 29 +++ .../routes/post-detail/config/tabs.ts | 83 +++++++ .../routes/post-detail/hooks/constants.ts | 15 ++ .../routes/post-detail/hooks/index.ts | 3 + .../post-detail/hooks/use-active-tab.ts | 48 ++++ .../hooks/use-post-detail-tab-layout.ts | 71 ++++++ .../post-detail/hooks/use-post-summary.ts | 77 +++++++ .../routes/post-detail/package.json | 30 +++ .../routes/post-detail/route.ts | 111 +++++++++ .../routes/post-detail/stage.module.scss | 27 +++ .../routes/post-detail/stage.tsx | 133 +++++++++++ .../routes/post-detail/style-imports.d.ts | 12 + 30 files changed, 1173 insertions(+), 204 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/add-WOOA7S-1622-post-detail-page create mode 100644 projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/index.ts create mode 100644 projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx create mode 100644 projects/packages/premium-analytics/routes/post-detail/components/index.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/components/post-detail-tabs/post-detail-tabs.module.scss create mode 100644 projects/packages/premium-analytics/routes/post-detail/components/post-detail-tabs/post-detail-tabs.tsx create mode 100644 projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.module.scss create mode 100644 projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.tsx create mode 100644 projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.module.scss create mode 100644 projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.tsx create mode 100644 projects/packages/premium-analytics/routes/post-detail/config/index.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/config/tab-layouts.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/config/tabs.test.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/config/tabs.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/hooks/constants.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/hooks/index.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/hooks/use-active-tab.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tab-layout.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/package.json create mode 100644 projects/packages/premium-analytics/routes/post-detail/route.ts create mode 100644 projects/packages/premium-analytics/routes/post-detail/stage.module.scss create mode 100644 projects/packages/premium-analytics/routes/post-detail/stage.tsx create mode 100644 projects/packages/premium-analytics/routes/post-detail/style-imports.d.ts diff --git a/projects/packages/premium-analytics/changelog/add-WOOA7S-1622-post-detail-page b/projects/packages/premium-analytics/changelog/add-WOOA7S-1622-post-detail-page new file mode 100644 index 000000000000..692f93a0589a --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-WOOA7S-1622-post-detail-page @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Premium Analytics: add the single post/page detail traffic view, with its own route, header, tabs, and customizable per-tab widget grids. diff --git a/projects/packages/premium-analytics/packages/routing/package.json b/projects/packages/premium-analytics/packages/routing/package.json index ab7952716d91..a2ac04f33faa 100644 --- a/projects/packages/premium-analytics/packages/routing/package.json +++ b/projects/packages/premium-analytics/packages/routing/package.json @@ -9,6 +9,10 @@ "dependencies": { "@jetpack-premium-analytics/data": "workspace:*", "@jetpack-premium-analytics/datetime": "workspace:*", - "@wordpress/route": "0.15.0" + "@wordpress/core-data": "7.49.1", + "@wordpress/data": "10.49.0", + "@wordpress/route": "0.15.0", + "date-fns": "4.1.0", + "react": "18.3.1" } } diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/index.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/index.ts index a29505a5a269..085638de1be4 100644 --- a/projects/packages/premium-analytics/packages/routing/src/hooks/index.ts +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/index.ts @@ -1 +1,2 @@ export { useStagedSearch } from './use-staged-search'; +export { useReportDateFilters, type ReportDateFilters } from './use-report-date-filters'; diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/index.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/index.ts new file mode 100644 index 000000000000..9e4bcd2fb690 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/index.ts @@ -0,0 +1 @@ +export { useReportDateFilters, type ReportDateFilters } from './use-report-date-filters'; diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx new file mode 100644 index 000000000000..c3f16e11178f --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx @@ -0,0 +1,212 @@ +/** + * External dependencies + */ +import { + getSiteTimezone, + localTZDate, + type ReportQueryParams, +} from '@jetpack-premium-analytics/data'; +import { + type ComparisonPresetId, + type DateRange, + type PrimaryPresetId, +} from '@jetpack-premium-analytics/datetime'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { endOfDay, isValid } from 'date-fns'; +import { useCallback, useMemo } from 'react'; +/** + * Internal dependencies + */ +import { deriveComparisonRange } from '../../search/comparison'; +import { encodeDateToSearchParam } from '../../search/date-range'; +import { useStagedSearch } from '../use-staged-search'; + +type ReportQuerySearchParams = Partial< + ReportQueryParams & { + preset?: PrimaryPresetId; + compare_preset?: ComparisonPresetId; + comp?: '1'; + } +>; + +/** + * The values and callbacks that drive `DateFiltersPanel`, minus the + * `containerElement` ref which the consuming page owns. + */ +type PickerRange = { from: Date | undefined; to: Date | undefined }; + +export type ReportDateFilters = { + presetId?: PrimaryPresetId; + range: PickerRange; + appliedPresetId?: PrimaryPresetId; + appliedRange: PickerRange; + comparisonPresetId?: ComparisonPresetId; + onChange: ( range?: DateRange, presetId?: PrimaryPresetId ) => void; + onComparisonChange: ( range: DateRange | undefined, presetId?: ComparisonPresetId ) => void; + onApply: () => void; + onCancel: () => void; + canApply: boolean; + timeZone: string; +}; + +/** + * Parse search-param dates into a picker range, dropping unparseable values to + * `undefined`. The picker reads these straight from the URL, so a malformed + * `from`/`to` (e.g. a hand-edited or under-encoded deep link where the `+` + * offset decoded to a space) must not become an invalid Date — `formatDate` + * throws "Invalid time value" on one and would white-screen the page. + * + * @param from - The `from` search param. + * @param to - The `to` search param. + * @return The parsed range, with invalid endpoints as `undefined`. + */ +function toPickerRange( from?: string, to?: string ) { + const parse = ( value?: string ) => { + if ( ! value ) { + return undefined; + } + const date = localTZDate( value ); + return isValid( date ) ? date : undefined; + }; + + return { + from: parse( from ), + to: parse( to ), + }; +} + +/** + * Controller for the date-range and comparison filters, backed by the URL + * search params on a given route. + * + * Edits are staged locally and committed atomically on Apply (or immediately + * for comparison changes), so widgets re-fetch only on commit. The hook returns + * everything `DateFiltersPanel` needs except the responsive-measurement + * `containerElement`, which the page owns. Shared by every analytics page that + * mounts the panel so the staged-search behavior stays identical across them. + * + * @param from - The route path the search params are bound to (e.g. `/`). + * @return Props for `DateFiltersPanel`. + */ +export function useReportDateFilters< TFrom extends string >( from: TFrom ): ReportDateFilters { + const { committed, effective, stage, commit, revert, isDirty } = useStagedSearch< + ReportQuerySearchParams, + TFrom + >( { from } ); + + const presetId = useMemo( () => effective.preset ?? undefined, [ effective.preset ] ); + const range = useMemo( + () => toPickerRange( effective.from, effective.to ), + [ effective.from, effective.to ] + ); + + const appliedPresetId = useMemo( () => committed.preset ?? undefined, [ committed.preset ] ); + const appliedRange = useMemo( + () => toPickerRange( committed.from, committed.to ), + [ committed.from, committed.to ] + ); + + const onChange = useCallback( + ( nextRange?: DateRange, nextPresetId?: PrimaryPresetId ) => { + if ( ! nextRange && ! nextPresetId ) { + return; + } + + if ( nextRange && nextRange.from && nextRange.to ) { + /* + * Stage `from` and `to` as ISO strings. The `to` date is adjusted + * to the end of the day, since the date picker core component sets + * the time to 00:00:00. + */ + const rangeFrom = encodeDateToSearchParam( nextRange.from ); + const rangeTo = encodeDateToSearchParam( endOfDay( nextRange.to ) ); + const patch: ReportQuerySearchParams = { from: rangeFrom, to: rangeTo }; + + /* + * When comparison is enabled, re-derive the comparison window from + * the new primary range so widgets compare against the matching + * previous period instead of the stale dates. + */ + if ( effective.comp === '1' ) { + const derived = deriveComparisonRange( { ...effective, from: rangeFrom, to: rangeTo } ); + if ( derived ) { + patch.compare_from = derived.compare_from; + patch.compare_to = derived.compare_to; + } + } + + stage( patch ); + } + + if ( nextPresetId ) { + stage( { preset: nextPresetId } ); + } + }, + [ stage, effective ] + ); + + const comparisonPresetId = useMemo( + () => effective.compare_preset ?? undefined, + [ effective.compare_preset ] + ); + + /** + * Comparison changes commit immediately — but only when the primary date + * isn't mid-edit. If a primary edit is staged but not yet applied, the + * comparison change rides along and commits together on Apply, so tweaking + * the comparison never commits an un-applied primary draft. + */ + const onComparisonChange = useCallback( + ( nextComparisonRange: DateRange | undefined, nextComparisonPresetId?: ComparisonPresetId ) => { + stage( { + compare_from: encodeDateToSearchParam( nextComparisonRange?.from ), + compare_to: encodeDateToSearchParam( nextComparisonRange?.to ), + compare_preset: nextComparisonPresetId ?? undefined, + comp: nextComparisonRange ? '1' : undefined, + } ); + + const hasPrimaryDraft = + effective.from !== committed.from || + effective.to !== committed.to || + effective.preset !== committed.preset; + + if ( ! hasPrimaryDraft ) { + commit(); + } + }, + [ stage, commit, effective, committed ] + ); + + const onApply = useCallback( () => commit(), [ commit ] ); + const onCancel = useCallback( () => revert(), [ revert ] ); + + /* + * Read the site timezone reactively. A fully-specified deep link skips the + * seed's `ensureCoreSettingsReady()` await, so core `site` settings may not + * be loaded on first paint; subscribing here re-renders with the real site + * timezone once they resolve, instead of sticking with the browser fallback. + */ + const timeZone = useSelect( select => { + void ( + select( coreStore ) as unknown as { + getEntityRecord: ( kind: string, name: string ) => unknown; + } + ).getEntityRecord( 'root', 'site' ); + return getSiteTimezone(); + }, [] ); + + return { + presetId, + range, + appliedPresetId, + appliedRange, + comparisonPresetId, + onChange, + onComparisonChange, + onApply, + onCancel, + canApply: isDirty, + timeZone, + }; +} diff --git a/projects/packages/premium-analytics/packages/routing/src/index.ts b/projects/packages/premium-analytics/packages/routing/src/index.ts index b6518f4c17a1..304034efcd3b 100644 --- a/projects/packages/premium-analytics/packages/routing/src/index.ts +++ b/projects/packages/premium-analytics/packages/routing/src/index.ts @@ -5,4 +5,4 @@ export { } from './search/date-range'; export { deriveComparisonRange } from './search/comparison'; -export { useStagedSearch } from './hooks'; +export { useStagedSearch, useReportDateFilters, type ReportDateFilters } from './hooks'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/README.md index aeb89e2ab19c..aef0d40b5692 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/README.md +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/README.md @@ -56,7 +56,7 @@ export function MyWidget() { | -------------- | -------------------------------------- | --------------------------------------------------------------- | | `attributes` | `Partial` | Widget attributes, may include `reportParams` | | `children` | `ReactNode` | Child components (widgets) | -| `options.from` | `string` | Router path for URL params (default: `/`) | +| `options.from` | `string` | Deprecated/ignored — params are always read from the current matched route | ### useWidgetRootContext diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/widget-root.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/widget-root.tsx index 88c4540de48d..11729961f562 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/widget-root.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/widget-root.tsx @@ -42,28 +42,31 @@ type WidgetRootProps = { */ options?: { /** - * The source of the search params. - * @default '/' + * Deprecated. Report params are now always read from the current matched + * route, so this no longer affects resolution. Retained for backward + * compatibility with widgets that still pass it. */ from?: string; }; }; -const DEFAULT_SEARCH_FROM = '/'; - /** * Hook that resolves widget attributes: * - `reportParams`: with URL search params when it's not provided */ -function useResolveReportParams( - attributes?: Partial< ReportParamsFieldAttributes >, - from?: string -) { +function useResolveReportParams( attributes?: Partial< ReportParamsFieldAttributes > ) { let search: Record< string, unknown > = {}; + /* + * Read the search params of the current route. `{ strict: false }` returns + * whatever route is matched, so widgets pick up the date range (and the + * single-resource scope like `post_id`) on any page — not only the dashboard + * at `/`. `useSearch` throws when rendered outside a matched route (e.g. + * Storybook), so the empty fallback stands in there. + */ try { // eslint-disable-next-line react-hooks/rules-of-hooks -- useSearch may throw outside a matched route - search = useSearch( { from: from ?? DEFAULT_SEARCH_FROM } ); + search = useSearch( { strict: false } ); } catch { // Do nothing } @@ -103,9 +106,9 @@ function useResolveReportParams( * } * ``` */ -export function WidgetRoot( { attributes, children, setError, options }: WidgetRootProps ) { +export function WidgetRoot( { attributes, children, setError }: WidgetRootProps ) { const chartTheme = useChartTheme(); - const rawReportParams = useResolveReportParams( attributes, options?.from ); + const rawReportParams = useResolveReportParams( attributes ); const { launchedDate } = getStoreInfo(); const defaultPreset = getDefaultPreset( launchedDate ); diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index 07c9b0173086..4b63cdc2bf02 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -1,24 +1,14 @@ -import { - getSiteTimezone, - GlobalErrorProvider, - localTZDate, - type ReportQueryParams, -} from '@jetpack-premium-analytics/data'; -import { - deriveComparisonRange, - encodeDateToSearchParam, - useStagedSearch, -} from '@jetpack-premium-analytics/routing'; +import { GlobalErrorProvider } from '@jetpack-premium-analytics/data'; +import { useReportDateFilters } from '@jetpack-premium-analytics/routing'; import { DateFiltersPanel } from '@jetpack-premium-analytics/ui'; import { Page } from '@wordpress/admin-ui'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; -import { useCallback, useMemo, useState } from '@wordpress/element'; +import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Tabs } from '@wordpress/ui'; import { WidgetDashboard } from '@wordpress/widget-dashboard'; import { useWidgetTypes, type WidgetModuleRecord } from '@wordpress/widget-primitives'; -import { endOfDay, isValid } from 'date-fns'; import { DashboardSections } from './components'; import { DASHBOARD_NAME, @@ -28,45 +18,6 @@ import { useDashboardSections, } from './hooks'; import styles from './stage.module.scss'; -import type { - ComparisonPresetId, - DateRange, - PrimaryPresetId, -} from '@jetpack-premium-analytics/datetime'; - -type ReportQuerySearchParams = Partial< - ReportQueryParams & { - preset?: PrimaryPresetId; - compare_preset?: ComparisonPresetId; - comp?: '1'; - } ->; - -/** - * Parse search-param dates into a picker range, dropping unparseable values to - * `undefined`. The picker reads these straight from the URL, so a malformed - * `from`/`to` (e.g. a hand-edited or under-encoded deep link where the `+` - * offset decoded to a space) must not become an invalid Date — `formatDate` - * throws "Invalid time value" on one and would white-screen the dashboard. - * - * @param {string} [from] - The `from` search param. - * @param {string} [to] - The `to` search param. - * @return {object} The parsed range, with invalid endpoints as `undefined`. - */ -function toPickerRange( from?: string, to?: string ) { - const parse = ( value?: string ) => { - if ( ! value ) { - return undefined; - } - const date = localTZDate( value ); - return isValid( date ) ? date : undefined; - }; - - return { - from: parse( from ), - to: parse( to ), - }; -} /** * Premium Analytics dashboard page stage component. @@ -105,136 +56,15 @@ function Dashboard(): JSX.Element { const [ editMode, setEditMode ] = useState( false ); /* - * Date-range state lives in the URL search params. Edits are staged - * locally and committed atomically on Apply (or immediately for + * Date-range state lives in the URL search params. The shared controller + * stages edits locally and commits atomically on Apply (or immediately for * comparison changes), so widgets re-fetch only on commit. */ - const { committed, effective, stage, commit, revert, isDirty } = useStagedSearch< - ReportQuerySearchParams, - '/' - >( { - from: '/', - } ); - - /* - * Staged preset/range are the in-progress draft: the open popover (calendar - * + trigger) reflects them live as the user edits. - */ - const presetId = useMemo( () => effective.preset ?? undefined, [ effective.preset ] ); - const range = useMemo( - () => toPickerRange( effective.from, effective.to ), - [ effective.from, effective.to ] - ); - - /* - * Committed preset/range are what's actually applied. The picker uses them - * to label its trigger while the popover is closed, so an accidental - * outside-click reverts the display to the applied range — while the staged - * draft above is kept and restored when the popover reopens. - */ - const appliedPresetId = useMemo( () => committed.preset ?? undefined, [ committed.preset ] ); - const appliedRange = useMemo( - () => toPickerRange( committed.from, committed.to ), - [ committed.from, committed.to ] - ); - - const onChange = useCallback( - ( nextRange?: DateRange, nextPresetId?: PrimaryPresetId ) => { - if ( ! nextRange && ! nextPresetId ) { - return; - } - - if ( nextRange && nextRange.from && nextRange.to ) { - /* - * Stage `from` and `to` as ISO strings. The `to` date is - * adjusted to the end of the day, since the date picker core - * component sets the time to 00:00:00. - */ - const from = encodeDateToSearchParam( nextRange.from ); - const to = encodeDateToSearchParam( endOfDay( nextRange.to ) ); - const patch: ReportQuerySearchParams = { from, to }; - - /* - * When comparison is enabled, re-derive the comparison window - * from the new primary range so widgets compare against the - * matching previous period instead of the stale dates. - */ - if ( effective.comp === '1' ) { - const derived = deriveComparisonRange( { ...effective, from, to } ); - if ( derived ) { - patch.compare_from = derived.compare_from; - patch.compare_to = derived.compare_to; - } - } - - stage( patch ); - } - - if ( nextPresetId ) { - stage( { preset: nextPresetId } ); - } - }, - [ stage, effective ] - ); - - const comparisonPresetId = useMemo( - () => effective.compare_preset ?? undefined, - [ effective.compare_preset ] - ); - - /** - * Comparison changes commit immediately — but only when the primary date - * isn't mid-edit. If a primary edit is staged but not yet applied, the - * comparison change rides along and commits together on Apply, so tweaking - * the comparison never commits an un-applied primary draft. - */ - const onComparisonChange = useCallback( - ( nextComparisonRange: DateRange | undefined, nextComparisonPresetId?: ComparisonPresetId ) => { - stage( { - compare_from: encodeDateToSearchParam( nextComparisonRange?.from ), - compare_to: encodeDateToSearchParam( nextComparisonRange?.to ), - compare_preset: nextComparisonPresetId ?? undefined, - comp: nextComparisonRange ? '1' : undefined, - } ); - - const hasPrimaryDraft = - effective.from !== committed.from || - effective.to !== committed.to || - effective.preset !== committed.preset; - - if ( ! hasPrimaryDraft ) { - commit(); - } - }, - [ stage, commit, effective, committed ] - ); - - const onApply = useCallback( () => { - commit(); - }, [ commit ] ); - - const onCancel = useCallback( () => { - revert(); - }, [ revert ] ); + const dateFilters = useReportDateFilters( '/' ); // Container element for the date filters panel responsive layout. const [ containerElement, setContainerElement ] = useState< HTMLDivElement | null >( null ); - /* - * Read the site timezone reactively. A fully-specified deep link skips the - * seed's `ensureCoreSettingsReady()` await, so core `site` settings may not - * be loaded on first paint; subscribing here re-renders with the real site - * timezone once they resolve, instead of sticking with the browser fallback. - */ - const timeZone = useSelect( select => { - void ( - select( coreStore ) as unknown as { - getEntityRecord: ( kind: string, name: string ) => unknown; - } - ).getEntityRecord( 'root', 'site' ); - return getSiteTimezone(); - }, [] ); - return ( - + { sections.map( section => ( diff --git a/projects/packages/premium-analytics/routes/post-detail/components/index.ts b/projects/packages/premium-analytics/routes/post-detail/components/index.ts new file mode 100644 index 000000000000..545385f78bfb --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/components/index.ts @@ -0,0 +1,3 @@ +export { PostDetailTabs } from './post-detail-tabs/post-detail-tabs'; +export { PostSummaryCard } from './post-summary-card/post-summary-card'; +export { StatsBreadcrumbs } from './stats-breadcrumbs/stats-breadcrumbs'; diff --git a/projects/packages/premium-analytics/routes/post-detail/components/post-detail-tabs/post-detail-tabs.module.scss b/projects/packages/premium-analytics/routes/post-detail/components/post-detail-tabs/post-detail-tabs.module.scss new file mode 100644 index 000000000000..ddada2ba4dd9 --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/components/post-detail-tabs/post-detail-tabs.module.scss @@ -0,0 +1,15 @@ +.tabList { + border-block-end: + var(--wpds-border-width-xs, 1px) solid + var(--wpds-color-stroke-surface-neutral-weak, #f0f0f0); + margin-block-end: var(--wpds-dimension-gap-lg, 16px); + padding-inline: var(--wpds-dimension-padding-2xl, 24px); +} + +.tabList :global([role="tab"][aria-selected="true"]) { + color: var(--wp-admin-theme-color, #3858e9); +} + +.tabList :global([role="tablist"] > [data-orientation="horizontal"]:not([role="tab"])) { + background-color: var(--wp-admin-theme-color, #3858e9); +} diff --git a/projects/packages/premium-analytics/routes/post-detail/components/post-detail-tabs/post-detail-tabs.tsx b/projects/packages/premium-analytics/routes/post-detail/components/post-detail-tabs/post-detail-tabs.tsx new file mode 100644 index 000000000000..6d159422f8be --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/components/post-detail-tabs/post-detail-tabs.tsx @@ -0,0 +1,63 @@ +import { Tabs } from '@wordpress/ui'; +import { useCallback } from 'react'; +import styles from './post-detail-tabs.module.scss'; +import type { PostDetailTab, PostDetailTabId } from '../../config'; +import type { ReactNode } from 'react'; + +type PostDetailTabsProps = { + /** + * The tabs to render, in order. + */ + tabs: PostDetailTab[]; + + /** + * The currently active tab ID. + */ + value: PostDetailTabId; + + /** + * Called with the new tab ID when the user selects a different tab. + */ + onChange: ( id: PostDetailTabId ) => void; + + /** + * Tab panel content. + */ + children?: ReactNode; +}; + +/** + * The post-detail tab bar. + * + * Purely presentational: it renders the tab triggers and reports selection + * changes upward. Panel children render inside the same Tabs.Root so the + * tablist and tab content share a complete tab/panel relationship. + * + * @param props - Component props. + * @param props.tabs - The tabs to render, in order. + * @param props.value - The currently active tab ID. + * @param props.onChange - Called with the new tab ID when the user selects a different tab. + * @param props.children - Tab panel content. + * @return The tab bar element. + */ +export function PostDetailTabs( { tabs, value, onChange, children }: PostDetailTabsProps ) { + const handleValueChange = useCallback( + ( tabId: string ) => onChange( tabId as PostDetailTabId ), + [ onChange ] + ); + + return ( + +
+ + { tabs.map( tab => ( + + { tab.label } + + ) ) } + +
+ { children } +
+ ); +} diff --git a/projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.module.scss b/projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.module.scss new file mode 100644 index 000000000000..a95c19673ca9 --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.module.scss @@ -0,0 +1,38 @@ +.card { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--wpds-dimension-gap-lg, 16px); + border: + var(--wpds-border-width-xs, 1px) solid + var(--wpds-color-stroke-surface-neutral-weak, #f0f0f0); + border-radius: var(--wpds-border-radius-md, 8px); + padding: var(--wpds-dimension-padding-2xl, 24px); +} + +.details { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-xs, 4px); + min-inline-size: 0; +} + +.type { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--wpds-dimension-gap-xs, 4px); + color: var(--wpds-color-fg-content-neutral-weak, #757575); +} + +.published { + color: var(--wpds-color-fg-content-neutral-weak, #757575); +} + +.image { + flex: 0 0 auto; + inline-size: 72px; + block-size: 72px; + object-fit: cover; + border-radius: var(--wpds-border-radius-sm, 6px); +} diff --git a/projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.tsx b/projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.tsx new file mode 100644 index 000000000000..9e23c7331255 --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.tsx @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { page as pageIcon, post as postIcon } from '@wordpress/icons'; +import { Icon, Text } from '@wordpress/ui'; +import { format, isValid } from 'date-fns'; +/** + * Internal dependencies + */ +import styles from './post-summary-card.module.scss'; +import type { PostSummary } from '../../hooks'; + +type PostSummaryCardProps = { + summary: PostSummary; +}; + +/** + * Get the display label for a post type slug. + * + * @param type - The post type slug. + * @return Human-readable type label. + */ +function getTypeLabel( type?: string ): string { + return type === 'page' + ? __( 'Page', 'jetpack-premium-analytics' ) + : __( 'Post', 'jetpack-premium-analytics' ); +} + +/** + * The header card summarizing the post/page being viewed: type badge, title, + * published date, and featured image. + * + * @param props - Component props. + * @param props.summary - The resolved post summary. + * @return The summary card element. + */ +export function PostSummaryCard( { summary }: PostSummaryCardProps ) { + const { title, type, publishedDate, imageUrl } = summary; + + const publishedDateObject = publishedDate ? new Date( publishedDate ) : undefined; + const publishedLabel = + publishedDateObject && isValid( publishedDateObject ) + ? sprintf( + /* translators: %s: the date a post was published, e.g. "Aug 19, 2025". */ + __( 'Published %s', 'jetpack-premium-analytics' ), + format( publishedDateObject, 'MMM d, yyyy' ) + ) + : undefined; + + return ( +
+
+
+ + { getTypeLabel( type ) } +
+ }> + { title } + + { publishedLabel ? ( + + { publishedLabel } + + ) : null } +
+ { imageUrl ? : null } +
+ ); +} diff --git a/projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.module.scss b/projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.module.scss new file mode 100644 index 000000000000..1a776552dfc4 --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.module.scss @@ -0,0 +1,18 @@ +.breadcrumbs { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-sm, 8px); +} + +.root { + color: var(--wpds-color-fg-content-neutral-weak, #757575); + text-decoration: none; +} + +.separator { + color: var(--wpds-color-fg-content-neutral-weak, #757575); +} + +.current { + color: var(--wpds-color-fg-content-neutral, #1e1e1e); +} diff --git a/projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.tsx b/projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.tsx new file mode 100644 index 000000000000..bf5832ee243e --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.tsx @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Link } from '@wordpress/route'; +import { Text } from '@wordpress/ui'; +/** + * Internal dependencies + */ +import styles from './stats-breadcrumbs.module.scss'; + +type StatsBreadcrumbsProps = { + /** + * The current post/page title, shown as the trailing crumb. + */ + title?: string; +}; + +/** + * The "Stats / " breadcrumb shown in the page header. + * + * The leading "Stats" crumb links back to the dashboard; the trailing crumb is + * the current resource's title. + * + * @param props - Component props. + * @param props.title - The current post/page title. + * @return The breadcrumb element. + */ +export function StatsBreadcrumbs( { title }: StatsBreadcrumbsProps ) { + return ( +
+ + { __( 'Stats', 'jetpack-premium-analytics' ) } + + { title ? ( + <> + + + { title } + + + ) : null } +
+ ); +} diff --git a/projects/packages/premium-analytics/routes/post-detail/config/index.ts b/projects/packages/premium-analytics/routes/post-detail/config/index.ts new file mode 100644 index 000000000000..990b6f1fe674 --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/config/index.ts @@ -0,0 +1,11 @@ +export { + POST_DETAIL_TAB_IDS, + DEFAULT_TAB_ID, + getTabLabel, + getPostDetailTabs, + resolveTabId, + type PostDetailTab, + type PostDetailTabId, +} from './tabs'; + +export { isPostDetailTabLayouts, type PostDetailTabLayouts } from './tab-layouts'; diff --git a/projects/packages/premium-analytics/routes/post-detail/config/tab-layouts.ts b/projects/packages/premium-analytics/routes/post-detail/config/tab-layouts.ts new file mode 100644 index 000000000000..2c1fc85df338 --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/config/tab-layouts.ts @@ -0,0 +1,23 @@ +import { POST_DETAIL_TAB_IDS } from './tabs'; +import type { PostDetailTabId } from './tabs'; +import type { DashboardWidget } from '@wordpress/widget-dashboard'; + +export type PostDetailTabLayouts = Partial< Record< PostDetailTabId, DashboardWidget[] > >; + +const TAB_IDS = new Set< string >( POST_DETAIL_TAB_IDS ); + +/** + * Check whether a value can be used as the persisted tab layout map. + * + * @param value - Candidate preference value. + * @return Whether the value is a valid tab layout map. + */ +export function isPostDetailTabLayouts( value: unknown ): value is PostDetailTabLayouts { + if ( ! value || typeof value !== 'object' || Array.isArray( value ) ) { + return false; + } + + return Object.entries( value ).every( + ( [ tabId, layout ] ) => TAB_IDS.has( tabId ) && Array.isArray( layout ) + ); +} diff --git a/projects/packages/premium-analytics/routes/post-detail/config/tabs.test.ts b/projects/packages/premium-analytics/routes/post-detail/config/tabs.test.ts new file mode 100644 index 000000000000..5eb172341dcc --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/config/tabs.test.ts @@ -0,0 +1,29 @@ +import { __ } from '@wordpress/i18n'; +import { DEFAULT_TAB_ID, POST_DETAIL_TAB_IDS, getPostDetailTabs, resolveTabId } from './tabs'; + +jest.mock( '@wordpress/i18n', () => ( { + __: jest.fn( ( text: string ) => text ), +} ) ); + +describe( 'post-detail tabs', () => { + it( 'builds the ordered tab definitions', () => { + expect( getPostDetailTabs() ).toEqual( [ + { id: 'post-traffic', label: 'Post traffic' }, + { id: 'email-opens', label: 'Email opens' }, + { id: 'email-clicks', label: 'Email clicks' }, + ] ); + + expect( __ ).toHaveBeenCalledWith( 'Post traffic', 'jetpack-premium-analytics' ); + } ); + + it( 'keeps the default tab first', () => { + expect( DEFAULT_TAB_ID ).toBe( 'post-traffic' ); + expect( POST_DETAIL_TAB_IDS[ 0 ] ).toBe( DEFAULT_TAB_ID ); + } ); + + it( 'resolves unknown tab search values to the default tab', () => { + expect( resolveTabId( 'email-opens' ) ).toBe( 'email-opens' ); + expect( resolveTabId( 'missing' ) ).toBe( DEFAULT_TAB_ID ); + expect( resolveTabId( undefined ) ).toBe( DEFAULT_TAB_ID ); + } ); +} ); diff --git a/projects/packages/premium-analytics/routes/post-detail/config/tabs.ts b/projects/packages/premium-analytics/routes/post-detail/config/tabs.ts new file mode 100644 index 000000000000..61c69f4b4494 --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/config/tabs.ts @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Ordered list of the post-detail tab IDs. + * + * This is the single source of truth for which tabs exist and in what order. + * Each tab is surfaced in the tab bar and renders its own customizable widget + * grid, so the IDs are kept stable and URL-friendly (they are persisted in the + * `?section=` search param, mirroring the dashboard). + */ +export const POST_DETAIL_TAB_IDS = [ 'post-traffic', 'email-opens', 'email-clicks' ] as const; + +/** + * Post-detail tab identifier. + * Derived from POST_DETAIL_TAB_IDS to keep the union in sync with the source list. + */ +export type PostDetailTabId = ( typeof POST_DETAIL_TAB_IDS )[ number ]; + +/** + * Default tab shown when the URL has no (or an unknown) tab param. + */ +export const DEFAULT_TAB_ID: PostDetailTabId = 'post-traffic'; + +/** + * A post-detail tab definition. + */ +export type PostDetailTab = { + id: PostDetailTabId; + label: string; +}; + +/** + * Canonical tab definitions with lazy label getters, in display order. + * + * Labels are defined once here, as getters resolved at call time, so translations + * are applied after the i18n locale data has loaded. Mirrors the dashboard's + * section definitions. + */ +const TAB_DEFINITIONS: ReadonlyArray< { + id: PostDetailTabId; + getLabel: () => string; +} > = [ + { id: 'post-traffic', getLabel: () => __( 'Post traffic', 'jetpack-premium-analytics' ) }, + { id: 'email-opens', getLabel: () => __( 'Email opens', 'jetpack-premium-analytics' ) }, + { id: 'email-clicks', getLabel: () => __( 'Email clicks', 'jetpack-premium-analytics' ) }, +]; + +/** + * Get the translated display label for a tab. + * + * @param id - The tab identifier. + * @return Translated label for the tab. + */ +export function getTabLabel( id: PostDetailTabId ): string { + return TAB_DEFINITIONS.find( tab => tab.id === id )?.getLabel() ?? id; +} + +/** + * Build the ordered list of tab definitions ({ id, label }). + * + * Labels are resolved lazily (at call time) so translations are applied after + * the i18n locale data has loaded. + * + * @return Ordered list of tab definitions. + */ +export function getPostDetailTabs(): PostDetailTab[] { + return TAB_DEFINITIONS.map( ( { id, getLabel } ) => ( { id, label: getLabel() } ) ); +} + +/** + * Narrow an arbitrary string to a known tab ID, falling back to the default. + * + * @param value - The candidate tab ID (e.g. from the URL). + * @return A valid tab ID. + */ +export function resolveTabId( value: string | undefined ): PostDetailTabId { + return value && ( POST_DETAIL_TAB_IDS as readonly string[] ).includes( value ) + ? ( value as PostDetailTabId ) + : DEFAULT_TAB_ID; +} diff --git a/projects/packages/premium-analytics/routes/post-detail/hooks/constants.ts b/projects/packages/premium-analytics/routes/post-detail/hooks/constants.ts new file mode 100644 index 000000000000..37b0ad574269 --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/hooks/constants.ts @@ -0,0 +1,15 @@ +/** + * Identifiers for the post-detail page's stored preferences. + * + * Kept separate from the main dashboard's scope so a post-detail tab layout + * never collides with a dashboard section layout: the two pages customize + * independent widget grids. + */ + +/** + * Preferences scope under which the post-detail tab layouts are stored. + */ +export const POST_DETAIL_PREFERENCES_SCOPE = 'jetpack-premium-analytics/post-detail'; + +/** Preferences key holding the per-tab layout map. */ +export const POST_DETAIL_TAB_LAYOUTS_KEY = 'postDetailTabLayouts'; diff --git a/projects/packages/premium-analytics/routes/post-detail/hooks/index.ts b/projects/packages/premium-analytics/routes/post-detail/hooks/index.ts new file mode 100644 index 000000000000..6e941dd03c4e --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/hooks/index.ts @@ -0,0 +1,3 @@ +export { useActiveTab } from './use-active-tab'; +export { usePostDetailTabLayout } from './use-post-detail-tab-layout'; +export { usePostSummary, type PostSummary } from './use-post-summary'; diff --git a/projects/packages/premium-analytics/routes/post-detail/hooks/use-active-tab.ts b/projects/packages/premium-analytics/routes/post-detail/hooks/use-active-tab.ts new file mode 100644 index 000000000000..e48a57a7f1fb --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/hooks/use-active-tab.ts @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { useStagedSearch } from '@jetpack-premium-analytics/routing'; +import { useCallback } from 'react'; +/** + * Internal dependencies + */ +import { resolveTabId, type PostDetailTabId } from '../config'; +import { route } from '../package.json'; + +/** + * Mirrors this route's `route.path` from package.json, so the staged-search + * `from` stays in sync if the mount path ever changes. + */ +const ROUTE_FROM = route.path; + +type TabSearch = { + section?: string; +}; + +/** + * Read and update the active tab via the `?section=` search param. + * + * Reuses the dashboard's `section` param name so both pages share one + * deep-linkable model for their tab state, built on `useStagedSearch`. + * Switching a tab is an immediate stage + commit (one history entry per + * change). + * + * @return A tuple of the active tab ID and a setter to change it. + */ +export function useActiveTab(): [ PostDetailTabId, ( id: PostDetailTabId ) => void ] { + const { effective, stage, commit } = useStagedSearch< TabSearch, typeof ROUTE_FROM >( { + from: ROUTE_FROM, + } ); + + const activeTab = resolveTabId( effective.section ); + + const setActiveTab = useCallback( + ( id: PostDetailTabId ) => { + stage( { section: id } ); + commit( { replace: false } ); + }, + [ stage, commit ] + ); + + return [ activeTab, setActiveTab ]; +} diff --git a/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tab-layout.ts b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tab-layout.ts new file mode 100644 index 000000000000..7da2da329ad5 --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tab-layout.ts @@ -0,0 +1,71 @@ +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; +import { useCallback, useMemo } from 'react'; +/** + * Internal dependencies + */ +import { isPostDetailTabLayouts } from '../config'; +import { POST_DETAIL_PREFERENCES_SCOPE, POST_DETAIL_TAB_LAYOUTS_KEY } from './constants'; +import type { PostDetailTabId, PostDetailTabLayouts } from '../config'; +import type { DashboardWidget } from '@wordpress/widget-dashboard'; + +const EMPTY_LAYOUT: DashboardWidget[] = []; +const EMPTY_TAB_LAYOUTS: PostDetailTabLayouts = {}; + +type PreferencesActions = { + set: ( scope: string, key: string, value: PostDetailTabLayouts ) => Promise< void > | void; +}; + +/** + * Manage the customizable widget layout for the currently active post-detail tab. + * + * Each tab customizes an independent widget grid, persisted under a single tab + * layout map in the post-detail preferences scope. The page ships no default + * widgets yet (the post-scoped widgets are ported separately), so a tab with no + * stored layout starts empty and reset clears it back to empty. + * + * @param activeTabId - Currently active tab ID. + * @return Active tab layout, setter, and reset action. + */ +export function usePostDetailTabLayout( + activeTabId: PostDetailTabId +): [ DashboardWidget[], ( layout: DashboardWidget[] ) => void, () => void ] { + const tabLayouts = useSelect( select => { + const value = ( + select( preferencesStore ) as unknown as { + get: ( scope: string, key: string ) => unknown; + } + ).get( POST_DETAIL_PREFERENCES_SCOPE, POST_DETAIL_TAB_LAYOUTS_KEY ); + + return isPostDetailTabLayouts( value ) ? value : EMPTY_TAB_LAYOUTS; + }, [] ); + + const { set } = useDispatch( preferencesStore ) as unknown as PreferencesActions; + + const layout = useMemo( + () => tabLayouts[ activeTabId ] ?? EMPTY_LAYOUT, + [ activeTabId, tabLayouts ] + ); + + const setLayout = useCallback( + ( nextLayout: DashboardWidget[] ) => { + void set( POST_DETAIL_PREFERENCES_SCOPE, POST_DETAIL_TAB_LAYOUTS_KEY, { + ...tabLayouts, + [ activeTabId ]: nextLayout, + } ); + }, + [ activeTabId, tabLayouts, set ] + ); + + const resetLayout = useCallback( () => { + void set( POST_DETAIL_PREFERENCES_SCOPE, POST_DETAIL_TAB_LAYOUTS_KEY, { + ...tabLayouts, + [ activeTabId ]: EMPTY_LAYOUT, + } ); + }, [ activeTabId, tabLayouts, set ] ); + + return [ layout, setLayout, resetLayout ]; +} diff --git a/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.ts b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.ts new file mode 100644 index 000000000000..465d0074d2b1 --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.ts @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import { useStatsPost } from '@jetpack-premium-analytics/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; + +type MediaEntity = { + source_url?: string; + media_details?: { sizes?: { thumbnail?: { source_url?: string } } }; +}; + +export type PostSummary = { + /** Post title for display. */ + title?: string; + /** Post type slug (e.g. `post`, `page`). */ + type?: string; + /** Published date as a datetime string, when available. */ + publishedDate?: string; + /** Featured image URL, when available. */ + imageUrl?: string; + /** Whether the underlying stats request is still resolving. */ + isLoading: boolean; +}; + +/** + * Resolve the header summary for a single post/page. + * + * Title, type, and published date come straight from the Stats `post` payload + * (the raw post row). The featured image isn't part of that payload, so it's + * read from the site's own post entity via `@wordpress/core-data`, degrading + * gracefully to `undefined` when the record or the featured media is missing. + * + * @param postId - The post/page ID from the route. + * @return The resolved post summary. + */ +export function usePostSummary( postId: number ): PostSummary { + // The header only needs the post row, so scope the query to the `post` field + // instead of pulling the full stats payload. + const { data, isLoading } = useStatsPost( { postId, fields: [ 'post' ] } ); + const post = data?.post; + const type = post?.post_type; + + const imageUrl = useSelect( + select => { + if ( ! type || ! Number.isInteger( postId ) || postId <= 0 ) { + return undefined; + } + + const core = select( coreStore ) as unknown as { + getEntityRecord: ( kind: string, name: string, key: number ) => unknown; + }; + + const entity = core.getEntityRecord( 'postType', type, postId ) as + | { featured_media?: number } + | undefined; + if ( ! entity?.featured_media ) { + return undefined; + } + + const media = core.getEntityRecord( 'postType', 'attachment', entity.featured_media ) as + | MediaEntity + | undefined; + + return media?.media_details?.sizes?.thumbnail?.source_url ?? media?.source_url ?? undefined; + }, + [ postId, type ] + ); + + return { + title: post?.post_title, + type, + publishedDate: post?.post_date_gmt ?? post?.post_date, + imageUrl, + isLoading, + }; +} diff --git a/projects/packages/premium-analytics/routes/post-detail/package.json b/projects/packages/premium-analytics/routes/post-detail/package.json new file mode 100644 index 000000000000..c9443624db9b --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/package.json @@ -0,0 +1,30 @@ +{ + "private": true, + "name": "@automattic/jetpack-premium-analytics-post-detail-route", + "route": { + "path": "/post/$postId", + "page": "jetpack-premium-analytics" + }, + "dependencies": { + "@automattic/jetpack-premium-analytics-data": "link:../../packages/data", + "@automattic/jetpack-script-data": "workspace:*", + "@jetpack-premium-analytics/data": "workspace:*", + "@jetpack-premium-analytics/datetime": "workspace:*", + "@jetpack-premium-analytics/routing": "workspace:*", + "@jetpack-premium-analytics/ui": "workspace:*", + "@wordpress/admin-ui": "2.1.0", + "@wordpress/core-data": "7.46.0", + "@wordpress/data": "10.46.0", + "@wordpress/element": "6.46.0", + "@wordpress/i18n": "^6.9.0", + "@wordpress/icons": "^13.0.0", + "@wordpress/preferences": "4.46.0", + "@wordpress/route": "0.14.1", + "@wordpress/ui": "0.13.0", + "@wordpress/widget-dashboard": "0.1.1-next.v.202606191442.0", + "@wordpress/widget-primitives": "0.1.1-next.v.202606191442.0", + "date-fns": "4.1.0", + "fast-deep-equal": "^3.1.3", + "react": "18.3.1" + } +} diff --git a/projects/packages/premium-analytics/routes/post-detail/route.ts b/projects/packages/premium-analytics/routes/post-detail/route.ts new file mode 100644 index 000000000000..81372be996ad --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/route.ts @@ -0,0 +1,111 @@ +/** + * External dependencies + */ +import { getScriptData } from '@automattic/jetpack-script-data'; +import { ensureCoreSettingsReady, normalizeReportParams } from '@jetpack-premium-analytics/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { dispatch, select } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { redirect } from '@wordpress/route'; +/** + * Internal dependencies + */ +import { DASHBOARD_REST_NAMESPACE } from '../dashboard/hooks/constants'; + +type PostDetailParams = { postId?: string }; +type PostDetailSearch = Record< string, string | undefined >; + +/** + * Route lifecycle for the post/page detail page. + * + * Guards mirror the dashboard (not connected → /connect, sync pending → + * /syncing). On first visit it seeds the URL search so the date picker and the + * widgets share a populated state, and it seeds `post_id` from the route param + * so every widget on the page is scoped to this single resource. The + * widget-modules discovery entity is registered here too (idempotently) so a + * direct deep link resolves widget types without first visiting the dashboard. + */ +export const route = { + beforeLoad: async ( { + params, + search, + }: { params?: PostDetailParams; search?: PostDetailSearch } = {} ) => { + const connectionStatus = getScriptData()?.connection?.connectionStatus; + + if ( ! connectionStatus?.isRegistered ) { + throw redirect( { to: '/connect' } ); + } + + const syncFinished = getScriptData()?.premium_analytics?.initial_full_sync_finished ?? 0; + if ( ! syncFinished ) { + throw redirect( { to: '/syncing' } ); + } + + // Only seed a scope for a valid positive-integer post/page ID, so a bad + // deep link like `/post/foo` never puts a malformed `post_id` into the URL. + const postId = + params?.postId && /^\d+$/.test( params.postId ) && Number( params.postId ) > 0 + ? params.postId + : undefined; + const currentSearch = ( search ?? {} ) as PostDetailSearch; + const needsDateSeed = ! currentSearch.from || ! currentSearch.to || ! currentSearch.interval; + const needsPostSeed = !! postId && currentSearch.post_id !== postId; + + if ( needsDateSeed || needsPostSeed ) { + /* + * Seed dates in the site timezone, not the browser's, by waiting for + * core `site` settings. A rejection here shouldn't error the whole + * page, so fall back to the default seed. + */ + try { + await ensureCoreSettingsReady(); + } catch { + // Proceed with the default seed below. + } + + const seeded: Record< string, unknown > = { + ...currentSearch, + ...normalizeReportParams( + currentSearch as Parameters< typeof normalizeReportParams >[ 0 ] + ), + ...( postId ? { post_id: postId } : {} ), + }; + + throw redirect( { + to: '/post/$postId', + /* + * The router is built dynamically, so `/post/$postId` has no + * statically-typed params/search schema (tanstack widens them to + * `never`). Cast the same way the routing package does when it + * writes the URL. Preserve the raw path param so the redirect stays + * on the current route even when the ID isn't a valid scope. + */ + params: { postId: params?.postId } as unknown as never, + replace: true, + search: seeded as unknown as never, + } ); + } + + const coreSelect = select( coreStore ) as unknown as { + getEntityConfig: ( kind: string, name: string ) => unknown; + }; + if ( coreSelect.getEntityConfig( 'root', 'widgetModule' ) ) { + return; + } + + const coreDispatch = dispatch( coreStore ) as unknown as { + addEntities: ( entities: object[] ) => void; + }; + coreDispatch.addEntities( [ + { + name: 'widgetModule', + kind: 'root', + key: 'name', + baseURL: `/${ DASHBOARD_REST_NAMESPACE }/widget-modules`, + plural: 'widgetModules', + label: __( 'Widget modules', 'jetpack-premium-analytics' ), + supportsPagination: false, + }, + ] ); + }, +}; diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.module.scss b/projects/packages/premium-analytics/routes/post-detail/stage.module.scss new file mode 100644 index 000000000000..3bd1dbb3d48e --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/stage.module.scss @@ -0,0 +1,27 @@ +.page { + min-block-size: 0; +} + +.content { + flex: 1 1 auto; + min-block-size: 0; + overflow-y: auto; + // Match the horizontal padding of the tab bar and header so the widget grid + // lines up with them instead of sitting flush to the edge. + padding-block-end: 24px; + padding-inline: var(--wpds-dimension-padding-2xl, 24px); +} + +.summary { + // Sits below the tab bar and above the date filters, aligned with the tabs + // and widget grid. + padding-block-end: var(--wpds-dimension-gap-lg, 16px); + padding-inline: var(--wpds-dimension-padding-2xl, 24px); +} + +.dateFilters { + // Sits below the summary card and above the widgets. Only pad below to keep + // the spacing even; inline padding matches the tabs and widget grid. + padding-block-end: var(--wpds-dimension-gap-lg, 16px); + padding-inline: var(--wpds-dimension-padding-2xl, 24px); +} diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.tsx b/projects/packages/premium-analytics/routes/post-detail/stage.tsx new file mode 100644 index 000000000000..7a75810e68eb --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/stage.tsx @@ -0,0 +1,133 @@ +import { AnalyticsQueryClientProvider, GlobalErrorProvider } from '@jetpack-premium-analytics/data'; +import { useReportDateFilters } from '@jetpack-premium-analytics/routing'; +import { DateFiltersPanel } from '@jetpack-premium-analytics/ui'; +import { Page } from '@wordpress/admin-ui'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { useMemo, useState } from '@wordpress/element'; +import { useParams } from '@wordpress/route'; +import { Tabs } from '@wordpress/ui'; +import { WidgetDashboard } from '@wordpress/widget-dashboard'; +import { useWidgetTypes, type WidgetModuleRecord } from '@wordpress/widget-primitives'; +// Grid settings are intentionally shared across analytics dashboards (see the +// hook's own note), so the post-detail page reuses the dashboard's hook rather +// than storing a separate copy. +import { useDashboardGridSettings } from '../dashboard/hooks/use-dashboard-grid-settings'; +import { PostDetailTabs, PostSummaryCard, StatsBreadcrumbs } from './components'; +import { getPostDetailTabs } from './config'; +import { useActiveTab, usePostDetailTabLayout, usePostSummary } from './hooks'; +import { route } from './package.json'; +import styles from './stage.module.scss'; + +const ROUTE_FROM = route.path; + +/** + * Premium Analytics post/page detail page stage component. + * + * Mirrors the dashboard — customizable, per-tab widget grids driven by a shared + * date range and comparison — but is scoped to a single post/page and carries + * its own header (breadcrumb + summary card) and tab set. + * + * @return {JSX.Element} The post detail page. + */ +function PostDetail(): JSX.Element { + const { postId: postIdParam } = useParams( { from: ROUTE_FROM } ) as { postId?: string }; + const postId = Number( postIdParam ); + + const tabs = useMemo( () => getPostDetailTabs(), [] ); + const [ activeTab, setActiveTab ] = useActiveTab(); + const [ layout, setLayout, resetLayout ] = usePostDetailTabLayout( activeTab ); + const [ gridSettings, setGridSettings ] = useDashboardGridSettings(); + + const summary = usePostSummary( postId ); + + const widgetModules = useSelect( + select => + ( + select( coreStore ) as unknown as { + getEntityRecords: ( kind: string, name: string ) => WidgetModuleRecord[] | null; + } + ).getEntityRecords( 'root', 'widgetModule' ), + [] + ); + + const [ widgetTypes, isResolvingWidgetTypes ] = useWidgetTypes( widgetModules ); + + const [ editMode, setEditMode ] = useState( false ); + + // The single resource, date range, and comparison all live in the URL search + // params, staged and committed by the shared date-filter controller. + const dateFilters = useReportDateFilters( ROUTE_FROM ); + + // Container element for the date filters panel responsive layout. + const [ containerElement, setContainerElement ] = useState< HTMLDivElement | null >( null ); + + return ( + + + } + actions={ } + className={ styles.page } + > + + { /* + * The summary card and date filters are shared by every tab + * (same post, same date range), so they render once below the + * tab bar and above the per-tab widget grid. + * + * The date-filters wrapper is also the responsive-measurement + * target: DateFiltersPanel reads its width to pick mobile/wide + * layouts instead of relying on the viewport. + */ } +
+ +
+
+ +
+ { tabs.map( tab => ( + + { activeTab === tab.id ? ( + <> + + + + ) : null } + + ) ) } +
+ + +
+
+
+ ); +} + +/** + * Route stage wrapper. + * + * The header summary fetches through React Query at the page level (widgets get + * their own client inside each WidgetRoot), so the page mounts its own + * AnalyticsQueryClientProvider above the component that reads it. + * + * @return {JSX.Element} The post detail page. + */ +export function stage(): JSX.Element { + return ( + + + + ); +} diff --git a/projects/packages/premium-analytics/routes/post-detail/style-imports.d.ts b/projects/packages/premium-analytics/routes/post-detail/style-imports.d.ts new file mode 100644 index 000000000000..42171ccf601c --- /dev/null +++ b/projects/packages/premium-analytics/routes/post-detail/style-imports.d.ts @@ -0,0 +1,12 @@ +declare module '*.module.css' { + const classes: { [ key: string ]: string }; + export default classes; +} + +declare module '*.module.scss' { + const classes: { [ key: string ]: string }; + export default classes; +} + +declare module '*.css'; +declare module '*.scss'; From 391a2e4dd3a8c89a87a7b3b8d961c64bc8c6ec45 Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Thu, 2 Jul 2026 18:16:05 +1200 Subject: [PATCH 2/2] Premium Analytics: address post-detail review feedback - Reset the widget dashboard's staging per tab by keying it on the active tab, and return to view mode on tab change, so staged widget edits can no longer leak across tabs or save under the wrong tab key. - Redirect malformed post IDs (e.g. /post/foo) to the dashboard instead of rendering site-wide stats under a single-post header; this also closes the spoof where a ?post_id= query on an invalid path bound the page to another post. - Carry the shared date range and comparison through the Stats breadcrumb so it and the browser Back button return to the same dashboard view. - Allowlist the params the page seeds (via normalizeReportParams + a validated section) instead of spreading the incoming search wholesale, so a link can't persist a foreign section or arbitrary params into the URL. Adds pickReportDateParams/REPORT_DATE_PARAM_KEYS to the routing package for the shared report-window params. --- .../packages/routing/src/index.ts | 1 + .../routing/src/search/report-params/index.ts | 1 + .../src/search/report-params/report-params.ts | 46 ++++++++++++++++ .../stats-breadcrumbs/stats-breadcrumbs.tsx | 11 +++- .../routes/post-detail/route.ts | 53 ++++++++++++++----- .../routes/post-detail/stage.tsx | 27 ++++++++-- 6 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 projects/packages/premium-analytics/packages/routing/src/search/report-params/index.ts create mode 100644 projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts diff --git a/projects/packages/premium-analytics/packages/routing/src/index.ts b/projects/packages/premium-analytics/packages/routing/src/index.ts index 304034efcd3b..4f5c43ee0db9 100644 --- a/projects/packages/premium-analytics/packages/routing/src/index.ts +++ b/projects/packages/premium-analytics/packages/routing/src/index.ts @@ -5,4 +5,5 @@ export { } from './search/date-range'; export { deriveComparisonRange } from './search/comparison'; +export { REPORT_DATE_PARAM_KEYS, pickReportDateParams } from './search/report-params'; export { useStagedSearch, useReportDateFilters, type ReportDateFilters } from './hooks'; diff --git a/projects/packages/premium-analytics/packages/routing/src/search/report-params/index.ts b/projects/packages/premium-analytics/packages/routing/src/search/report-params/index.ts new file mode 100644 index 000000000000..d82c880bdc89 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/search/report-params/index.ts @@ -0,0 +1 @@ +export { REPORT_DATE_PARAM_KEYS, pickReportDateParams } from './report-params'; diff --git a/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts b/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts new file mode 100644 index 000000000000..1436d14aeb96 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts @@ -0,0 +1,46 @@ +/** + * The URL search params that describe the shared report window (date range, + * interval, and comparison) — the state every analytics surface has in common. + * + * Page-owned scope params such as `post_id` and `section` are deliberately + * excluded, so this set is safe to carry between routes without leaking one + * page's scope onto another. + */ +export const REPORT_DATE_PARAM_KEYS = [ + 'from', + 'to', + 'interval', + 'preset', + 'period', + 'date_type', + 'compare_from', + 'compare_to', + 'compare_preset', + 'comp', +] as const; + +/** + * Pick only the shared report-window params from a URL search object. + * + * Used when navigating between analytics routes (e.g. a detail page back to the + * dashboard) to carry the date range and comparison through without also + * carrying page-scoped params like `post_id` or `section`. + * + * @param search - The current route search params. + * @return A new object with only the shared report-window params that are set. + */ +export function pickReportDateParams( + search: Record< string, unknown > | undefined +): Record< string, unknown > { + if ( ! search ) { + return {}; + } + + const picked: Record< string, unknown > = {}; + for ( const key of REPORT_DATE_PARAM_KEYS ) { + if ( search[ key ] !== undefined ) { + picked[ key ] = search[ key ]; + } + } + return picked; +} diff --git a/projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.tsx b/projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.tsx index bf5832ee243e..d4157f1f6388 100644 --- a/projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.tsx +++ b/projects/packages/premium-analytics/routes/post-detail/components/stats-breadcrumbs/stats-breadcrumbs.tsx @@ -1,8 +1,9 @@ /** * External dependencies */ +import { pickReportDateParams } from '@jetpack-premium-analytics/routing'; import { __ } from '@wordpress/i18n'; -import { Link } from '@wordpress/route'; +import { Link, useSearch } from '@wordpress/route'; import { Text } from '@wordpress/ui'; /** * Internal dependencies @@ -27,9 +28,15 @@ type StatsBreadcrumbsProps = { * @return The breadcrumb element. */ export function StatsBreadcrumbs( { title }: StatsBreadcrumbsProps ) { + // Carry the shared date range and comparison back to the dashboard so the + // breadcrumb and the browser Back button return to the same view. Page-scoped + // params (`post_id`, `section`) are dropped by `pickReportDateParams`. + const search = useSearch( { strict: false } ) as Record< string, unknown >; + const dashboardSearch = pickReportDateParams( search ); + return (
- + { __( 'Stats', 'jetpack-premium-analytics' ) } { title ? ( diff --git a/projects/packages/premium-analytics/routes/post-detail/route.ts b/projects/packages/premium-analytics/routes/post-detail/route.ts index 81372be996ad..73e51e701b62 100644 --- a/projects/packages/premium-analytics/routes/post-detail/route.ts +++ b/projects/packages/premium-analytics/routes/post-detail/route.ts @@ -11,10 +11,21 @@ import { redirect } from '@wordpress/route'; * Internal dependencies */ import { DASHBOARD_REST_NAMESPACE } from '../dashboard/hooks/constants'; +import { resolveTabId } from './config'; type PostDetailParams = { postId?: string }; type PostDetailSearch = Record< string, string | undefined >; +/** + * Whether a raw path param is a valid single-post scope (a positive integer). + * + * @param value - The raw `postId` path param. + * @return Whether it identifies a post/page. + */ +function isValidPostId( value: string | undefined ): value is string { + return !! value && /^\d+$/.test( value ) && Number( value ) > 0; +} + /** * Route lifecycle for the post/page detail page. * @@ -41,17 +52,29 @@ export const route = { throw redirect( { to: '/syncing' } ); } - // Only seed a scope for a valid positive-integer post/page ID, so a bad - // deep link like `/post/foo` never puts a malformed `post_id` into the URL. - const postId = - params?.postId && /^\d+$/.test( params.postId ) && Number( params.postId ) > 0 - ? params.postId - : undefined; + // A malformed path param (e.g. `/post/foo`) has no single-post view to + // show, and letting it through would render site-wide stats under a + // single-post header. Send it back to the dashboard rather than present + // unscoped data as if it were scoped. This also closes the spoof where a + // `?post_id=` query on an invalid path could bind the page to a different + // post than the URL claims. + const postId = params?.postId; + if ( ! isValidPostId( postId ) ) { + throw redirect( { to: '/' } ); + } + const currentSearch = ( search ?? {} ) as PostDetailSearch; + // A `section` carried in from a link may not be a valid post-detail tab + // (e.g. a dashboard section forwarded by a widget link); resolve it so a + // shareable URL never persists a bogus tab. + const resolvedSection = currentSearch.section + ? resolveTabId( currentSearch.section ) + : undefined; const needsDateSeed = ! currentSearch.from || ! currentSearch.to || ! currentSearch.interval; - const needsPostSeed = !! postId && currentSearch.post_id !== postId; + const needsPostSeed = currentSearch.post_id !== postId; + const needsSectionSeed = !! currentSearch.section && resolvedSection !== currentSearch.section; - if ( needsDateSeed || needsPostSeed ) { + if ( needsDateSeed || needsPostSeed || needsSectionSeed ) { /* * Seed dates in the site timezone, not the browser's, by waiting for * core `site` settings. A rejection here shouldn't error the whole @@ -63,12 +86,17 @@ export const route = { // Proceed with the default seed below. } + // Allowlist the params this page owns rather than spreading + // `currentSearch` wholesale: `normalizeReportParams` yields only the + // known report-window params, and the path-derived `post_id` is the + // single source of scope. This contains any foreign params a link + // carried in (e.g. a dashboard `section`) instead of persisting them. const seeded: Record< string, unknown > = { - ...currentSearch, ...normalizeReportParams( currentSearch as Parameters< typeof normalizeReportParams >[ 0 ] ), - ...( postId ? { post_id: postId } : {} ), + ...( resolvedSection ? { section: resolvedSection } : {} ), + post_id: postId, }; throw redirect( { @@ -77,10 +105,9 @@ export const route = { * The router is built dynamically, so `/post/$postId` has no * statically-typed params/search schema (tanstack widens them to * `never`). Cast the same way the routing package does when it - * writes the URL. Preserve the raw path param so the redirect stays - * on the current route even when the ID isn't a valid scope. + * writes the URL. */ - params: { postId: params?.postId } as unknown as never, + params: { postId } as unknown as never, replace: true, search: seeded as unknown as never, } ); diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.tsx b/projects/packages/premium-analytics/routes/post-detail/stage.tsx index 7a75810e68eb..66392a512911 100644 --- a/projects/packages/premium-analytics/routes/post-detail/stage.tsx +++ b/projects/packages/premium-analytics/routes/post-detail/stage.tsx @@ -4,7 +4,7 @@ import { DateFiltersPanel } from '@jetpack-premium-analytics/ui'; import { Page } from '@wordpress/admin-ui'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; -import { useMemo, useState } from '@wordpress/element'; +import { useCallback, useMemo, useState } from '@wordpress/element'; import { useParams } from '@wordpress/route'; import { Tabs } from '@wordpress/ui'; import { WidgetDashboard } from '@wordpress/widget-dashboard'; @@ -14,7 +14,7 @@ import { useWidgetTypes, type WidgetModuleRecord } from '@wordpress/widget-primi // than storing a separate copy. import { useDashboardGridSettings } from '../dashboard/hooks/use-dashboard-grid-settings'; import { PostDetailTabs, PostSummaryCard, StatsBreadcrumbs } from './components'; -import { getPostDetailTabs } from './config'; +import { getPostDetailTabs, type PostDetailTabId } from './config'; import { useActiveTab, usePostDetailTabLayout, usePostSummary } from './hooks'; import { route } from './package.json'; import styles from './stage.module.scss'; @@ -55,6 +55,18 @@ function PostDetail(): JSX.Element { const [ editMode, setEditMode ] = useState( false ); + // Switching tabs returns to view mode. Edit mode is page-level, and an empty + // tab force-enables it via the dashboard provider's empty-layout effect, so + // without this a non-empty tab would stay stuck in customize view after + // visiting an empty one. + const handleTabChange = useCallback( + ( id: PostDetailTabId ) => { + setEditMode( false ); + setActiveTab( id ); + }, + [ setActiveTab ] + ); + // The single resource, date range, and comparison all live in the URL search // params, staged and committed by the shared date-filter controller. const dateFilters = useReportDateFilters( ROUTE_FROM ); @@ -64,7 +76,16 @@ function PostDetail(): JSX.Element { return ( + { /* + * Key the dashboard by the active tab so its staging layout resets on + * every switch. Empty tabs otherwise share one `EMPTY_LAYOUT` identity, + * and the provider only resets staging when the `layout` prop identity + * changes — so staged-but-unsaved widgets could render on, and be saved + * under, the wrong tab. Remount cost is negligible (only the active + * tab's panel renders). + */ } } className={ styles.page } > - + { /* * The summary card and date filters are shared by every tab * (same post, same date range), so they render once below the