@flamingo-stack/openframe-frontend-core 0.0.756 → 0.0.758

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-7KPU7RGS.cjs → chunk-2AEGJHW2.cjs} +83 -82
  3. package/dist/{chunk-QKFJ6ORK.cjs → chunk-3C4U4UW6.cjs} +1 -1
  4. package/dist/{chunk-57JA37HP.cjs → chunk-5THYJU3G.cjs} +123 -77
  5. package/dist/{chunk-4YL4Y3BV.cjs → chunk-ECHJO4NI.cjs} +48 -24
  6. package/dist/{chunk-TAEEGNFL.cjs → chunk-GU3M4YF3.cjs} +75 -68
  7. package/dist/{chunk-MDXZMTCK.cjs → chunk-IQFXJSAH.cjs} +22 -22
  8. package/dist/{chunk-KNNJ4WLF.cjs → chunk-JEIAB7JQ.cjs} +2 -2
  9. package/dist/{chunk-MBQTLZAG.cjs → chunk-L2RNZOCI.cjs} +11 -11
  10. package/dist/{chunk-VOXDBCBV.cjs → chunk-N2US7DLB.cjs} +47 -36
  11. package/dist/{chunk-KFUC2XF7.cjs → chunk-RPADJ2E6.cjs} +3 -3
  12. package/dist/{chunk-PUK27ZB6.cjs → chunk-RUMRBL5X.cjs} +13 -13
  13. package/dist/{chunk-CYDWQYIQ.cjs → chunk-SEPHTWFL.cjs} +5 -5
  14. package/dist/{chunk-W5MEHJRQ.cjs → chunk-U2UVB4IF.cjs} +2 -2
  15. package/dist/{chunk-BTIYXBA2.cjs → chunk-WSRMYHIV.cjs} +2 -2
  16. package/dist/{chunk-KSU3CXKY.cjs → chunk-YBOA5GZV.cjs} +3 -3
  17. package/dist/{chunk-MR5KMPSE.cjs → chunk-YLVTECAD.cjs} +4 -4
  18. package/dist/{cloud-tenants-NOZGXQZY.cjs → cloud-tenants-WJCL4P3Y.cjs} +3 -3
  19. package/dist/components/case-studies/index.cjs +11 -11
  20. package/dist/components/chat/embeddable-chat.js +8 -6
  21. package/dist/components/chat/index.cjs +2 -2
  22. package/dist/components/contact/index.cjs +4 -4
  23. package/dist/components/docs/doc-viewer.d.ts +4 -0
  24. package/dist/components/docs/doc-viewer.d.ts.map +1 -1
  25. package/dist/components/docs/doc-viewer.js +3 -0
  26. package/dist/components/docs/index.cjs +7 -7
  27. package/dist/components/docs/use-document-tree.d.ts.map +1 -1
  28. package/dist/components/docs/use-document-tree.js +9 -1
  29. package/dist/components/embeds/index.cjs +4 -4
  30. package/dist/components/faq/index.cjs +5 -5
  31. package/dist/components/features/index.cjs +3 -3
  32. package/dist/components/help-center-pages/index.cjs +57 -56
  33. package/dist/components/help-center-pages/trust-center-page.d.ts +7 -1
  34. package/dist/components/help-center-pages/trust-center-page.d.ts.map +1 -1
  35. package/dist/components/help-center-pages/trust-center-page.js +4 -3
  36. package/dist/components/index.cjs +34 -32
  37. package/dist/components/layout/page-data-sync.d.ts +18 -0
  38. package/dist/components/layout/page-data-sync.d.ts.map +1 -0
  39. package/dist/components/layout/page-data-sync.js +33 -0
  40. package/dist/components/meeting-scheduler/index.cjs +19 -19
  41. package/dist/components/navigation/index.cjs +6 -6
  42. package/dist/components/onboarding-guides/index.cjs +5 -5
  43. package/dist/components/product-demo/index.cjs +7 -7
  44. package/dist/components/shared/delivery/delivery-lists.d.ts.map +1 -1
  45. package/dist/components/shared/delivery/delivery-lists.js +6 -0
  46. package/dist/components/shared/dev-section/dev-section-view.d.ts +3 -16
  47. package/dist/components/shared/dev-section/dev-section-view.d.ts.map +1 -1
  48. package/dist/components/shared/dev-section/dev-section-view.js +6 -13
  49. package/dist/components/shared/legal-document/legal-document-page.d.ts +1 -3
  50. package/dist/components/shared/legal-document/legal-document-page.d.ts.map +1 -1
  51. package/dist/components/shared/legal-document/legal-document-page.js +3 -9
  52. package/dist/components/shared/roadmap/roadmap-view.d.ts.map +1 -1
  53. package/dist/components/shared/roadmap/roadmap-view.js +29 -17
  54. package/dist/components/tickets/hooks/use-tickets-list.d.ts +4 -0
  55. package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
  56. package/dist/components/tickets/hooks/use-tickets-list.js +1 -0
  57. package/dist/components/tickets/index.cjs +8 -8
  58. package/dist/components/tickets/ticket-center.d.ts.map +1 -1
  59. package/dist/components/tickets/ticket-center.js +5 -6
  60. package/dist/components/ui/data-attribution.d.ts +13 -0
  61. package/dist/components/ui/data-attribution.d.ts.map +1 -1
  62. package/dist/components/ui/data-attribution.js +30 -1
  63. package/dist/components/ui/index.cjs +5 -3
  64. package/dist/{devices-M4KBTOTC.cjs → devices-PCMSALOA.cjs} +4 -4
  65. package/dist/{embeddable-chat-KGJVVG2V.cjs → embeddable-chat-CHHWPGAY.cjs} +8 -6
  66. package/dist/index.cjs +13 -7
  67. package/dist/{knowledge-VVF6CJEG.cjs → knowledge-FVCXNAMW.cjs} +3 -3
  68. package/dist/{logs-7E6HBJ55.cjs → logs-IINFWMUF.cjs} +11 -11
  69. package/dist/{policies-UMZLJTBD.cjs → policies-P3ZT774G.cjs} +7 -7
  70. package/dist/{scripts-XVNAEYCH.cjs → scripts-KO4V2XO5.cjs} +8 -8
  71. package/dist/{software-update-U43ZFHMC.cjs → software-update-PSJUU363.cjs} +6 -6
  72. package/dist/types/data-sync.d.ts +22 -0
  73. package/dist/types/data-sync.d.ts.map +1 -0
  74. package/dist/types/data-sync.js +12 -0
  75. package/dist/types/doc-source.d.ts +4 -0
  76. package/dist/types/doc-source.d.ts.map +1 -1
  77. package/dist/types/index.cjs +12 -0
  78. package/dist/types/index.d.ts +1 -0
  79. package/dist/types/index.d.ts.map +1 -1
  80. package/dist/types/index.js +1 -0
  81. package/package.json +1 -1
  82. package/src/components/chat/embeddable-chat.tsx +10 -6
  83. package/src/components/docs/doc-viewer.tsx +9 -0
  84. package/src/components/docs/use-document-tree.ts +10 -0
  85. package/src/components/help-center-pages/trust-center-page.tsx +10 -3
  86. package/src/components/layout/page-data-sync.tsx +60 -0
  87. package/src/components/shared/delivery/delivery-lists.tsx +12 -2
  88. package/src/components/shared/dev-section/dev-section-view.tsx +37 -56
  89. package/src/components/shared/legal-document/legal-document-page.tsx +5 -13
  90. package/src/components/shared/roadmap/roadmap-view.tsx +37 -20
  91. package/src/components/tickets/hooks/use-tickets-list.ts +7 -0
  92. package/src/components/tickets/ticket-center.tsx +7 -3
  93. package/src/components/ui/data-attribution.tsx +44 -0
  94. package/src/types/data-sync.ts +26 -0
  95. package/src/types/doc-source.ts +4 -0
  96. package/src/types/index.ts +1 -0
@@ -31,11 +31,14 @@
31
31
  import { useEffect, useState } from 'react';
32
32
  import { useSearchParams, useRouter, usePathname } from '../../../embed-shims';
33
33
  import { useScrollToHash } from '../../../hooks/use-scroll-to-hash';
34
+ import type { DataSync } from '../../../types/data-sync';
34
35
  import type { DeliveryItem, DeliveryResponse } from '../../../types/delivery';
35
36
  import { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys';
36
37
  import { contentFetch } from '../../../utils/embed-content-fetch';
37
38
  import { STICKY_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav';
39
+ import { usePageDataSync } from '../../layout/page-data-sync';
38
40
  import { ListEmptyState } from '../../list-empty-state';
41
+ import { DataSyncLine } from '../../ui/data-attribution';
39
42
  import { LoadError } from '../../ui/error-state';
40
43
  import { DeliveryTable } from './delivery-table';
41
44
 
@@ -47,13 +50,15 @@ const DEFAULT_SEARCH_PARAM_KEY = DEV_SECTION_PARAM_KEYS.search;
47
50
  const DEFAULT_TASK_TYPE_PARAM_KEY = DEV_SECTION_PARAM_KEYS.deliveryTaskType;
48
51
 
49
52
  /**
50
- * Wire shape of ONE bucket endpoint — `{ items, count, pagination }`. Only
51
- * `items` is read here; `items` stays optional because an embedder's proxy is
53
+ * Wire shape of ONE bucket endpoint — `{ items, count, pagination, sync }`. Only
54
+ * `items` and `sync` are read here; `items` stays optional because an embedder's proxy is
52
55
  * free to answer with an empty envelope, and the render treats "no items" and
53
56
  * "empty items" identically.
54
57
  */
55
58
  interface DeliveryBucketResponse {
56
59
  items?: DeliveryItem[];
60
+ /** Where the tasks are mirrored from and when it last read them (both buckets read the same mirror). */
61
+ sync?: DataSync;
57
62
  }
58
63
 
59
64
  export interface DeliveryListsProps {
@@ -84,6 +89,7 @@ export function DeliveryLists({
84
89
  const [data, setData] = useState<DeliveryResponse | null>(null);
85
90
  const [isLoading, setIsLoading] = useState(true);
86
91
  const [error, setError] = useState<string | null>(null);
92
+ const [sync, setSync] = useState<DataSync | null>(null);
87
93
 
88
94
  // Get filter state from URL
89
95
  const searchQuery = searchParams.get(searchParamKey) || '';
@@ -134,6 +140,7 @@ export function DeliveryLists({
134
140
  completed: completedResult.items ?? [],
135
141
  inProgress: inProgressResult.items ?? [],
136
142
  });
143
+ setSync(completedResult.sync ?? inProgressResult.sync ?? null);
137
144
  } catch (err) {
138
145
  if (cancelled) return;
139
146
  console.error('Error fetching delivery items:', err);
@@ -160,6 +167,8 @@ export function DeliveryLists({
160
167
  // by RoadmapView). 96 matches the sticky-header offset every
161
168
  // hash-scroll surface in the app uses.
162
169
  useScrollToHash(data, { headerOffset: STICKY_HEADER_OFFSET_PX });
170
+ // The page states the sync under its title; the lists shown without that page draw it above themselves.
171
+ const syncInPage = usePageDataSync(sync, isLoading && !data);
163
172
 
164
173
  const showCompleted = true;
165
174
  const showInProgress = true;
@@ -180,6 +189,7 @@ export function DeliveryLists({
180
189
 
181
190
  return (
182
191
  <div className="flex w-full flex-col gap-[40px]">
192
+ {!syncInPage && <DataSyncLine sync={sync} loading={isLoading && !data} />}
183
193
  {/* Empty state if no results after filtering */}
184
194
  {!isLoading && !hasResults && (
185
195
  <ListEmptyState
@@ -23,26 +23,15 @@ import {
23
23
  type OpenframeDevSectionKey,
24
24
  } from '../../../utils/dev-sections/openframe-dev-sections';
25
25
  import { StatusFilterComponent } from '../../features';
26
+ import { PageDataSyncProvider, PageDataSyncRow } from '../../layout/page-data-sync';
27
+ import { SectionHeading } from '../../layout/page-heading';
26
28
  import { SearchInput } from '../../ui';
27
29
 
28
30
  export interface DevSectionViewProps {
29
31
  /** Which section to render — drives title, search, and filter
30
32
  * config via the `OPENFRAME_DEV_SECTIONS` registry. */
31
33
  sectionKey: OpenframeDevSectionKey;
32
- /** When set, renders the rich page-level hero (icon + h1 + description).
33
- * Omit for the compact tab-context heading. */
34
- hero?: {
35
- /** Pre-rendered icon JSX. Server components render the icon themselves
36
- * and pass the element here — function references can't cross the
37
- * server→client boundary, but React elements can. */
38
- icon: ReactNode;
39
- /** Hero title. Falls back to `OPENFRAME_DEV_SECTIONS[sectionKey].hero.title`
40
- * when omitted, so embedders can override the (OpenFrame-specific) default
41
- * copy without forking the registry. */
42
- title?: string;
43
- description: string;
44
- };
45
- /** Optional slot rendered BETWEEN the hero and the search/filter
34
+ /** Optional slot rendered BETWEEN the heading and the search/filter
46
35
  * controls. Use this for an entry-action surface that should sit
47
36
  * above the list (e.g. the Help Center's "Open a new ticket" form).
48
37
  * The slot is wrapped in the same `gap-10` flex column so spacing
@@ -52,7 +41,7 @@ export interface DevSectionViewProps {
52
41
  /** The page-specific list body. Reads URL params written by this
53
42
  * component (search input + filter pills). */
54
43
  children: ReactNode;
55
- /** Render this component's own title heading (hero or compact `h2`). Default
44
+ /** Render this component's own title heading (the section's `h2`). Default
56
45
  * `true` — the tab context renders the compact heading itself. Pass `false`
57
46
  * when the host already renders the title elsewhere (e.g. `DevSectionPage`
58
47
  * routes it through the unified `PageLayout` `TitleBlock`): then only the
@@ -60,7 +49,7 @@ export interface DevSectionViewProps {
60
49
  showHeading?: boolean;
61
50
  }
62
51
 
63
- export function DevSectionView({ sectionKey, hero, preControls, children, showHeading = true }: DevSectionViewProps) {
52
+ export function DevSectionView({ sectionKey, preControls, children, showHeading = true }: DevSectionViewProps) {
64
53
  const section = OPENFRAME_DEV_SECTIONS[sectionKey];
65
54
  const router = useRouter();
66
55
  const pathname = usePathname();
@@ -123,50 +112,42 @@ export function DevSectionView({ sectionKey, hero, preControls, children, showHe
123
112
  router.replace(`${pathname}?${params.toString()}`, { scroll: false });
124
113
  };
125
114
 
115
+ // The list reports where its records are mirrored from; the line states it at the end of the section's heading row
116
+ // (under the page's title when this view shows no heading of its own).
126
117
  return (
127
- <div className="flex w-full flex-col gap-10">
128
- {showHeading &&
129
- (hero ? (
130
- <div className="space-y-4">
131
- <h1 className="flex items-center gap-3 tracking-[-1.12px] text-ods-text-primary text-h1">
132
- {hero.icon}
133
- {hero.title ?? section.hero.title}
134
- </h1>
135
- <p className="max-w-3xl text-ods-text-secondary text-h4">{hero.description}</p>
136
- </div>
118
+ <PageDataSyncProvider>
119
+ <div className="flex w-full flex-col gap-10">
120
+ {showHeading ? (
121
+ <SectionHeading title={section.hero.title} punctuation=":" action={<PageDataSyncRow inline />} />
137
122
  ) : (
138
- <div className="flex w-full items-center justify-between">
139
- <h2 className="font-['Azeret_Mono'] text-[32px] font-semibold leading-[40px] tracking-[-0.64px] text-ods-text-primary content-md:text-[40px] content-md:leading-[48px] content-md:tracking-[-0.8px] content-lg:text-[48px] content-lg:leading-[56px] content-lg:tracking-[-0.96px]">
140
- {section.hero.title}
141
- <span className="text-ods-accent">:</span>
142
- </h2>
143
- </div>
144
- ))}
123
+ <PageDataSyncRow />
124
+ )}
145
125
 
146
- {preControls}
126
+ {preControls}
147
127
 
148
- {(search || filter) && (
149
- <div className="space-y-4">
150
- {search && (
151
- <SearchInput
152
- showDropdown={false}
153
- placeholder={search.placeholder}
154
- value={searchValue}
155
- onChange={handleSearchChange}
156
- onSubmit={handleSearchSubmit}
157
- />
158
- )}
159
- {filter && (
160
- <StatusFilterComponent
161
- selectedStatus={currentFilterValue}
162
- onStatusChange={handleFilterChange}
163
- statusOptions={[...filter.options]}
164
- />
165
- )}
166
- </div>
167
- )}
128
+ {(search || filter) && (
129
+ <div className="space-y-4">
130
+ {search && (
131
+ <SearchInput
132
+ showDropdown={false}
133
+ placeholder={search.placeholder}
134
+ value={searchValue}
135
+ onChange={handleSearchChange}
136
+ onSubmit={handleSearchSubmit}
137
+ />
138
+ )}
139
+ {filter && (
140
+ <StatusFilterComponent
141
+ selectedStatus={currentFilterValue}
142
+ onStatusChange={handleFilterChange}
143
+ statusOptions={[...filter.options]}
144
+ />
145
+ )}
146
+ </div>
147
+ )}
168
148
 
169
- {children}
170
- </div>
149
+ {children}
150
+ </div>
151
+ </PageDataSyncProvider>
171
152
  );
172
153
  }
@@ -20,10 +20,10 @@
20
20
  import type { ComponentType } from 'react';
21
21
  import Link from '../../../embed-shims/next-link';
22
22
  import { useRouter } from '../../../embed-shims/next-navigation';
23
- import { formatLegalDate } from '../../../utils/format';
24
23
  import { FaqSection, pageFaqProps, type PageFaqSlot } from '../../faq/faq-section';
25
24
  import { UnifiedSkeleton } from '../../loading/unified-skeleton';
26
25
  import { PageShell, PageLayout } from '../../ui';
26
+ import { DataSyncLine } from '../../ui/data-attribution';
27
27
  import { RichMarkdownRenderer } from '../../ui/markdown';
28
28
  import { useLegalDocs, type LegalDocument } from './use-legal-docs';
29
29
  import { useLegalDocuments } from './use-legal-documents';
@@ -56,8 +56,6 @@ export interface LegalDocumentPageProps {
56
56
  emptyStateMessage?: string;
57
57
  /** SSR-prepared document, if available. */
58
58
  initialData?: LegalDocument | null;
59
- /** SSR-prepared formatted "Last Updated" label. Stable across hydration. */
60
- initialLastUpdatedLabel?: string | null;
61
59
  /** Override the default `/api/legal/<docType>` endpoint
62
60
  * (reverse-proxy embedders, alternate API paths). */
63
61
  apiEndpoint?: string;
@@ -109,7 +107,6 @@ export function LegalDocumentPage({
109
107
  errorTitle: errorTitleProp,
110
108
  emptyStateMessage: emptyStateMessageProp,
111
109
  initialData = null,
112
- initialLastUpdatedLabel = null,
113
110
  apiEndpoint,
114
111
  MarkdownRenderer = RichMarkdownRenderer,
115
112
  documentsEndpoint,
@@ -139,21 +136,16 @@ export function LegalDocumentPage({
139
136
  const errorTitle = errorTitleProp ?? `Unable to load ${title}`;
140
137
  const emptyStateMessage = emptyStateMessageProp ?? `${title} is not available at this time.`;
141
138
 
142
- const fallbackLastUpdatedLabel = data?.lastSynced != null ? formatLegalDate(data.lastSynced) : null;
143
- const effectiveLastUpdatedLabel = initialLastUpdatedLabel ?? fallbackLastUpdatedLabel;
144
-
145
139
  // Subtitle routes through the frozen `PageLayout` `TitleBlock` (text-h2 title
146
140
  // + subtitle) — unified header across all help-center pages. A document that
147
141
  // states its own version and effective date shows those (they are what a
148
- // reader agreed to); one that states neither shows when it was last read
149
- // from its source, else the fallback description.
150
- const subtitle =
151
- legalDocumentStampLabel(data) ??
152
- (effectiveLastUpdatedLabel ? `Last Updated: ${effectiveLastUpdatedLabel}` : fallbackDescription);
142
+ // reader agreed to), else the fallback description. When it was last read from
143
+ // its repository is the shared sync line under the title.
144
+ const subtitle = legalDocumentStampLabel(data) ?? fallbackDescription;
153
145
 
154
146
  const inner = (
155
147
  <PageLayout title={title} subtitle={subtitle} backButton={backCfg} titleSize="h1" titleWrap>
156
- {data?.sourceFile && <p className="text-ods-text-secondary opacity-75 text-h6">Source: {data.sourceFile}</p>}
148
+ <DataSyncLine sync={data ? { source: 'github', syncedAt: data.lastSynced } : null} loading={isLoading && !data} />
157
149
 
158
150
  <div className="flex flex-1 flex-col items-start gap-6 lg:flex-row lg:gap-10">
159
151
  <div className="flex-1">
@@ -15,15 +15,20 @@ import { useMemo } from 'react';
15
15
  import { useSearchParams } from '../../../embed-shims';
16
16
  import { useScrollToHash } from '../../../hooks/use-scroll-to-hash';
17
17
  import { useSelfFetch } from '../../../hooks/use-self-fetch';
18
+ import type { DataSync } from '../../../types/data-sync';
18
19
  import { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys';
19
20
  import { STICKY_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav';
20
21
  import type { RoadmapItem } from '../../chat/types/entities/roadmap-item';
22
+ import { usePageDataSync } from '../../layout/page-data-sync';
23
+ import { DataSyncLine } from '../../ui/data-attribution';
21
24
  import { LoadError } from '../../ui/error-state';
22
25
  import { RoadmapGrid } from './roadmap-grid';
23
26
  import { RoadmapGridSkeleton } from './roadmap-grid-skeleton';
24
27
  import type { UseRoadmapVotingOptions } from './use-roadmap-voting';
25
28
 
26
29
  const DEFAULT_ENDPOINT = '/api/roadmap';
30
+ /** The list under its sync line (the line the endpoint's `sync` states). */
31
+ const SYNCED_LIST_CLASS = 'flex w-full flex-col gap-[var(--spacing-system-lf)]';
27
32
  // Defaults sourced from the ONE param-key registry the chrome (OPENFRAME_DEV_SECTIONS) also
28
33
  // reads, so the chrome's written `?key=` and this view's read can't silently diverge.
29
34
  const DEFAULT_SEARCH_PARAM_KEY = DEV_SECTION_PARAM_KEYS.search;
@@ -73,7 +78,9 @@ export function RoadmapView({
73
78
  // hook's initialData re-sync effect fires every render and clobbers the
74
79
  // optimistic vote patch below.
75
80
  const initialData = useMemo(() => (initialItems ? { items: initialItems } : undefined), [initialItems]);
76
- const { data, setData, isLoading, error, reload } = useSelfFetch<{ items?: RoadmapItem[] }>(url, { initialData });
81
+ const { data, setData, isLoading, error, reload } = useSelfFetch<{ items?: RoadmapItem[]; sync?: DataSync }>(url, {
82
+ initialData,
83
+ });
77
84
  const items = data?.items ?? [];
78
85
 
79
86
  // Deep-link hash dispatch — `?search=<id>#roadmap-<id>` from a chat card.
@@ -84,6 +91,8 @@ export function RoadmapView({
84
91
  // them when `hasActiveFilters` is true (chat URL carries `?search=<id>`).
85
92
  // The card mounts one tick after `data` lands; the hook waits.
86
93
  useScrollToHash(data, { headerOffset: STICKY_HEADER_OFFSET_PX });
94
+ // The page states the sync under its title; a roadmap shown without that page draws it above the list.
95
+ const syncInPage = usePageDataSync(data?.sync, isLoading && !data);
87
96
 
88
97
  if (error) {
89
98
  return <LoadError message="Failed to load roadmap." onRetry={reload} />;
@@ -91,27 +100,35 @@ export function RoadmapView({
91
100
  // Skeleton only while the FIRST fetch is in flight (no data yet) — a malformed
92
101
  // body lacking `items` renders the grid (empty), never a stuck skeleton.
93
102
  if (isLoading && !data) {
94
- return <RoadmapGridSkeleton showLeftMargin={showLeftMargin} />;
103
+ return (
104
+ <div className={SYNCED_LIST_CLASS}>
105
+ {!syncInPage && <DataSyncLine loading />}
106
+ <RoadmapGridSkeleton showLeftMargin={showLeftMargin} />
107
+ </div>
108
+ );
95
109
  }
96
110
 
97
111
  return (
98
- <RoadmapGrid
99
- items={items}
100
- showLeftMargin={showLeftMargin}
101
- buildRefreshUrl={buildRefreshUrl}
102
- votingOptions={votingOptions}
103
- // Full-page roadmap → collapsible quarter grouping (the shared RoadmapGrid
104
- // capability; flat grids stay the RoadmapGrid default). When the chrome's
105
- // search / status filter is active, expand every quarter so matches aren't hidden.
106
- groupByQuarter
107
- hasActiveFilters={search !== '' || status !== 'all'}
108
- // After a vote refreshes a single task, patch it into the fetched list so
109
- // the displayed counts stay live.
110
- onItemUpdate={updated =>
111
- setData(prev =>
112
- prev ? { ...prev, items: (prev.items ?? []).map(it => (it.id === updated.id ? updated : it)) } : prev,
113
- )
114
- }
115
- />
112
+ <div className={SYNCED_LIST_CLASS}>
113
+ {!syncInPage && <DataSyncLine sync={data?.sync} />}
114
+ <RoadmapGrid
115
+ items={items}
116
+ showLeftMargin={showLeftMargin}
117
+ buildRefreshUrl={buildRefreshUrl}
118
+ votingOptions={votingOptions}
119
+ // Full-page roadmap → collapsible quarter grouping (the shared RoadmapGrid
120
+ // capability; flat grids stay the RoadmapGrid default). When the chrome's
121
+ // search / status filter is active, expand every quarter so matches aren't hidden.
122
+ groupByQuarter
123
+ hasActiveFilters={search !== '' || status !== 'all'}
124
+ // After a vote refreshes a single task, patch it into the fetched list so
125
+ // the displayed counts stay live.
126
+ onItemUpdate={updated =>
127
+ setData(prev =>
128
+ prev ? { ...prev, items: (prev.items ?? []).map(it => (it.id === updated.id ? updated : it)) } : prev,
129
+ )
130
+ }
131
+ />
132
+ </div>
116
133
  );
117
134
  }
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { useQuery } from '@tanstack/react-query';
23
23
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context';
24
+ import type { DataSync } from '../../../types/data-sync';
24
25
  import { embedAuthedFetch } from '../../../utils/embed-authed-fetch';
25
26
  import { PAGE_PARAM_LIMITS, pageCount, positiveInt } from '../../../utils/search-params';
26
27
  import type { TicketData } from '../types';
@@ -36,6 +37,8 @@ interface FindTicketResponse {
36
37
  pageSize?: number;
37
38
  totalPages?: number;
38
39
  scope?: 'self' | 'all';
40
+ /** Where the tickets are mirrored from and when it last read them. */
41
+ sync?: DataSync;
39
42
  }
40
43
 
41
44
  export interface UseTicketsListFilters {
@@ -71,6 +74,9 @@ export interface UseTicketsListReturn {
71
74
  /** Wall-clock timestamp of the last successful fetch; null while
72
75
  * loading the first time. */
73
76
  lastUpdatedAt: number | null;
77
+ /** Where the tickets are mirrored from and when that mirror last read them (the endpoint's `sync`); null when the
78
+ * host states none. */
79
+ sync: DataSync | null;
74
80
  /** Total ticket count across all pages (NOT just the current page).
75
81
  * Drives the `<UnifiedPagination>` total-pages calculation. */
76
82
  totalCount: number;
@@ -168,6 +174,7 @@ export function useTicketsList(filters: UseTicketsListFilters): UseTicketsListRe
168
174
  void query.refetch();
169
175
  },
170
176
  lastUpdatedAt: query.dataUpdatedAt || null,
177
+ sync: data?.sync ?? null,
171
178
  totalCount,
172
179
  page: echoedPage,
173
180
  pageSize: echoedPageSize,
@@ -19,14 +19,15 @@ import { useQueryClient } from '@tanstack/react-query';
19
19
  import { RefreshCw } from 'lucide-react';
20
20
  import { useCallback, useState } from 'react';
21
21
  import { toast as defaultToast } from '../../hooks/use-toast';
22
- import { formatRelativeTime } from '../../utils/date-utils';
23
22
  import { devSectionAnchorId } from '../../utils/dev-sections/dev-section-param-keys';
24
23
  import { useChatIdentity } from '../chat/hooks/use-chat-identity';
25
24
  import { EmptyState } from '../empty-state';
25
+ import { usePageDataSync } from '../layout/page-data-sync';
26
26
  import { LoadingRegion } from '../loading/loading-region';
27
27
  import { UnifiedSkeleton } from '../loading/unified-skeleton';
28
28
  import { Button } from '../ui/button';
29
29
  import { Card } from '../ui/card';
30
+ import { DataSyncLine } from '../ui/data-attribution';
30
31
  import { useTicketActions } from './hooks/use-ticket-actions';
31
32
  import { useTicketsList } from './hooks/use-tickets-list';
32
33
  import { TicketOpenForm } from './ticket-open-form';
@@ -71,7 +72,7 @@ function TicketCenterAuthed({
71
72
  sessionEmail: string;
72
73
  }) {
73
74
  const queryClient = useQueryClient();
74
- const { tickets, isLoading, isFetching, refetch, lastUpdatedAt } = useTicketsList({
75
+ const { tickets, isLoading, isFetching, refetch, sync } = useTicketsList({
75
76
  customerEmail: sessionEmail,
76
77
  });
77
78
  const [optimisticTickets, setOptimisticTickets] = useState<OptimisticTicket[]>([]);
@@ -117,6 +118,9 @@ function TicketCenterAuthed({
117
118
  setExpandedTicketId(prev => (prev === id ? null : id));
118
119
  }, []);
119
120
 
121
+ // The page states the sync under its title; the ticket list shown without that page draws it in its own header.
122
+ const syncInPage = usePageDataSync(sync, isLoading);
123
+
120
124
  const merged: AnyTicket[] = [...optimisticTickets, ...tickets];
121
125
 
122
126
  return (
@@ -131,7 +135,7 @@ function TicketCenterAuthed({
131
135
  <div className="flex items-center justify-between gap-3">
132
136
  <p className="text-ods-text-secondary text-h5">Your Current Tickets</p>
133
137
  <div className="flex items-center gap-3 text-ods-text-secondary text-h6">
134
- {lastUpdatedAt && <span>Updated {formatRelativeTime(new Date(lastUpdatedAt))}</span>}
138
+ {!syncInPage && <DataSyncLine sync={sync} inline />}
135
139
  <Button
136
140
  type="button"
137
141
  variant="transparent"
@@ -1,5 +1,9 @@
1
1
  import type { ReactNode } from 'react';
2
+ import { DATA_SYNC_SOURCES, type DataSync, type DataSyncSource } from '../../types/data-sync';
2
3
  import { formatDate } from '../../utils/format';
4
+ import { ClickupIcon } from '../icons-v2-generated/brand-logos/clickup-icon';
5
+ import { GithubClassicIcon } from '../icons-v2-generated/brand-logos/github-classic-icon';
6
+ import { HubspotIcon } from '../icons-v2-generated/brand-logos/hubspot-icon';
3
7
  import { TextSkeleton, UnifiedSkeleton } from '../loading/unified-skeleton';
4
8
 
5
9
  export interface DataAttributionProps {
@@ -61,3 +65,43 @@ export function DataAttributionSkeleton() {
61
65
  </div>
62
66
  );
63
67
  }
68
+
69
+ /** The mark each sync source is drawn with (the app's standard brand marks); its name is `DATA_SYNC_SOURCES`'. */
70
+ const DATA_SYNC_ICONS: Record<DataSyncSource, typeof ClickupIcon> = {
71
+ clickup: ClickupIcon,
72
+ hubspot: HubspotIcon,
73
+ github: GithubClassicIcon,
74
+ };
75
+
76
+ /**
77
+ * THE sync line of a shared page whose records an API reads from a mirror (the roadmap and the delivery board:
78
+ * ClickUp; the ticket center: HubSpot; the docs and the legal documents: GitHub). The page draws whatever `sync`
79
+ * its endpoint answers, so a host that states none shows no line. `loading` draws the line's skeleton, so the page
80
+ * does not move when it fills in. It sits on its own row, at the end; `inline` leaves it in the caller's row.
81
+ */
82
+ export function DataSyncLine({
83
+ sync,
84
+ loading = false,
85
+ inline = false,
86
+ action,
87
+ }: {
88
+ sync?: DataSync | null;
89
+ loading?: boolean;
90
+ inline?: boolean;
91
+ action?: ReactNode;
92
+ }) {
93
+ if (!loading && !(sync && sync.source in DATA_SYNC_ICONS)) return null;
94
+ const Icon = sync ? DATA_SYNC_ICONS[sync.source] : null;
95
+ const line =
96
+ loading || !sync || !Icon ? (
97
+ <DataAttributionSkeleton />
98
+ ) : (
99
+ <DataAttribution
100
+ icon={<Icon size={16} />}
101
+ source={DATA_SYNC_SOURCES[sync.source]}
102
+ lastUpdated={sync.syncedAt}
103
+ action={action}
104
+ />
105
+ );
106
+ return inline ? line : <div className="flex w-full justify-end">{line}</div>;
107
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Every system a shared page's records are mirrored from, with the name a page shows for it. THE list: `DataSync`,
3
+ * `DataSyncLine`'s marks, the docs hook and every host that answers a `sync` read it.
4
+ */
5
+ export const DATA_SYNC_SOURCES = {
6
+ clickup: 'ClickUp',
7
+ hubspot: 'HubSpot',
8
+ github: 'GitHub',
9
+ } as const;
10
+
11
+ export type DataSyncSource = keyof typeof DATA_SYNC_SOURCES;
12
+
13
+ export function isDataSyncSource(value: unknown): value is DataSyncSource {
14
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(DATA_SYNC_SOURCES, value);
15
+ }
16
+
17
+ /**
18
+ * Where a list's records are mirrored from and when that mirror last read them: the `sync` a shared page's
19
+ * endpoint answers beside its records, drawn by `DataSyncLine`. Server-safe.
20
+ */
21
+ export interface DataSync {
22
+ /** The system the records are read from. */
23
+ source: DataSyncSource;
24
+ /** ISO instant of the mirror's last successful read, or null when it never ran. */
25
+ syncedAt: string | null;
26
+ }
@@ -1,3 +1,4 @@
1
+ import type { DataSync } from './data-sync';
1
2
  /**
2
3
  * Single home for doc-source tree + content types.
3
4
  *
@@ -55,6 +56,9 @@ export interface DocContent {
55
56
  * from the underlying row's sync/update timestamp. Undefined when unknown. */
56
57
  publishedAt?: string;
57
58
  updatedAt?: string;
59
+ /** Where the document is mirrored from and when it was last read (a synced docs source only); the viewer draws
60
+ * it as the shared sync line. */
61
+ sync?: DataSync;
58
62
  }
59
63
 
60
64
  /**
@@ -6,6 +6,7 @@ export * from './announcement';
6
6
  // Doc-source viewer types — DocNode / DocContent / DocSourceDal /
7
7
  // DocRenderHandlers. Consumed by knowledge-base, data-room, and future
8
8
  // doc-source viewers.
9
+ export * from './data-sync';
9
10
  export * from './doc-source';
10
11
  export type { Category, CategoryCardProps, RealCategoryCardProps } from './categories';
11
12
  export * from './category';