@flamingo-stack/openframe-frontend-core 0.0.207 → 0.0.208

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 (132) hide show
  1. package/dist/{chunk-Z3GQGR5E.js → chunk-2HMZSCJY.js} +3158 -2074
  2. package/dist/chunk-2HMZSCJY.js.map +1 -0
  3. package/dist/chunk-4XLJWX2N.js +12 -0
  4. package/dist/chunk-4XLJWX2N.js.map +1 -0
  5. package/dist/{chunk-APM6KBPU.cjs → chunk-C5EC5AZM.cjs} +1644 -560
  6. package/dist/chunk-C5EC5AZM.cjs.map +1 -0
  7. package/dist/chunk-VFKQMAUF.cjs +12 -0
  8. package/dist/chunk-VFKQMAUF.cjs.map +1 -0
  9. package/dist/components/chat/embeddable-chat.d.ts +35 -2
  10. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  11. package/dist/components/chat/hooks/index.d.ts +3 -0
  12. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  13. package/dist/components/chat/hooks/use-embedded-chat.d.ts +10 -169
  14. package/dist/components/chat/hooks/use-embedded-chat.d.ts.map +1 -1
  15. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +85 -0
  16. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -0
  17. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +124 -0
  18. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -0
  19. package/dist/components/chat/hooks/use-unified-chat.d.ts +33 -0
  20. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -0
  21. package/dist/components/chat/index.cjs +8 -2
  22. package/dist/components/chat/index.cjs.map +1 -1
  23. package/dist/components/chat/index.js +11 -5
  24. package/dist/components/chat/types/index.d.ts +1 -0
  25. package/dist/components/chat/types/index.d.ts.map +1 -1
  26. package/dist/components/chat/types/unified-chat-state.types.d.ts +185 -0
  27. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -0
  28. package/dist/components/features/index.cjs +3 -2
  29. package/dist/components/features/index.cjs.map +1 -1
  30. package/dist/components/features/index.js +2 -1
  31. package/dist/components/index.cjs +26 -2
  32. package/dist/components/index.cjs.map +1 -1
  33. package/dist/components/index.d.ts +4 -0
  34. package/dist/components/index.d.ts.map +1 -1
  35. package/dist/components/index.js +27 -3
  36. package/dist/components/navigation/index.cjs +3 -2
  37. package/dist/components/navigation/index.cjs.map +1 -1
  38. package/dist/components/navigation/index.js +2 -1
  39. package/dist/components/shared/delivery/delivery-lists.d.ts +16 -0
  40. package/dist/components/shared/delivery/delivery-lists.d.ts.map +1 -0
  41. package/dist/components/shared/delivery/delivery-table.d.ts +12 -0
  42. package/dist/components/shared/delivery/delivery-table.d.ts.map +1 -0
  43. package/dist/components/shared/delivery/index.d.ts +3 -0
  44. package/dist/components/shared/delivery/index.d.ts.map +1 -0
  45. package/dist/components/shared/dev-section/dev-section-page.d.ts +31 -0
  46. package/dist/components/shared/dev-section/dev-section-page.d.ts.map +1 -0
  47. package/dist/components/shared/dev-section/dev-section-view.d.ts +34 -0
  48. package/dist/components/shared/dev-section/dev-section-view.d.ts.map +1 -0
  49. package/dist/components/shared/dev-section/index.d.ts +3 -0
  50. package/dist/components/shared/dev-section/index.d.ts.map +1 -0
  51. package/dist/components/shared/legal-document/index.d.ts +10 -0
  52. package/dist/components/shared/legal-document/index.d.ts.map +1 -0
  53. package/dist/components/shared/legal-document/legal-document-page.d.ts +66 -0
  54. package/dist/components/shared/legal-document/legal-document-page.d.ts.map +1 -0
  55. package/dist/components/shared/legal-document/use-legal-docs.d.ts +40 -0
  56. package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -0
  57. package/dist/components/shared/product-release/index.d.ts +2 -1
  58. package/dist/components/shared/product-release/index.d.ts.map +1 -1
  59. package/dist/components/shared/product-release/release-detail-page.d.ts +11 -7
  60. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  61. package/dist/components/shared/roadmap/index.d.ts +18 -0
  62. package/dist/components/shared/roadmap/index.d.ts.map +1 -0
  63. package/dist/components/shared/roadmap/roadmap-grid-skeleton.d.ts +24 -0
  64. package/dist/components/shared/roadmap/roadmap-grid-skeleton.d.ts.map +1 -0
  65. package/dist/components/shared/roadmap/roadmap-grid.d.ts +18 -0
  66. package/dist/components/shared/roadmap/roadmap-grid.d.ts.map +1 -0
  67. package/dist/components/shared/roadmap/use-roadmap-voting.d.ts +25 -0
  68. package/dist/components/shared/roadmap/use-roadmap-voting.d.ts.map +1 -0
  69. package/dist/components/ui/index.cjs +8 -2
  70. package/dist/components/ui/index.cjs.map +1 -1
  71. package/dist/components/ui/index.js +11 -5
  72. package/dist/components/ui/release-changelog-section.d.ts +13 -2
  73. package/dist/components/ui/release-changelog-section.d.ts.map +1 -1
  74. package/dist/embed-shims/index.cjs +1 -6
  75. package/dist/embed-shims/index.cjs.map +1 -1
  76. package/dist/embed-shims/index.js +1 -6
  77. package/dist/embed-shims/index.js.map +1 -1
  78. package/dist/index.cjs +18 -2
  79. package/dist/index.cjs.map +1 -1
  80. package/dist/index.js +19 -3
  81. package/dist/types/delivery.d.ts +49 -0
  82. package/dist/types/delivery.d.ts.map +1 -0
  83. package/dist/types/index.cjs +13 -0
  84. package/dist/types/index.cjs.map +1 -1
  85. package/dist/types/index.d.ts +1 -0
  86. package/dist/types/index.d.ts.map +1 -1
  87. package/dist/types/index.js +12 -1
  88. package/dist/types/index.js.map +1 -1
  89. package/dist/utils/dev-sections/index.d.ts +11 -0
  90. package/dist/utils/dev-sections/index.d.ts.map +1 -0
  91. package/dist/utils/dev-sections/openframe-dev-sections.d.ts +209 -0
  92. package/dist/utils/dev-sections/openframe-dev-sections.d.ts.map +1 -0
  93. package/dist/utils/index.cjs +82 -0
  94. package/dist/utils/index.cjs.map +1 -1
  95. package/dist/utils/index.d.ts +1 -0
  96. package/dist/utils/index.d.ts.map +1 -1
  97. package/dist/utils/index.js +81 -2
  98. package/dist/utils/index.js.map +1 -1
  99. package/package.json +1 -1
  100. package/src/components/chat/embeddable-chat.tsx +123 -8
  101. package/src/components/chat/hooks/index.ts +9 -2
  102. package/src/components/chat/hooks/use-embedded-chat.ts +18 -1016
  103. package/src/components/chat/hooks/use-nats-chat-adapter.ts +372 -0
  104. package/src/components/chat/hooks/use-sse-chat-adapter.ts +1058 -0
  105. package/src/components/chat/hooks/use-unified-chat.ts +171 -0
  106. package/src/components/chat/types/index.ts +1 -0
  107. package/src/components/chat/types/unified-chat-state.types.ts +215 -0
  108. package/src/components/index.ts +8 -0
  109. package/src/components/shared/delivery/delivery-lists.tsx +199 -0
  110. package/src/components/shared/delivery/delivery-table.tsx +174 -0
  111. package/src/components/shared/delivery/index.ts +9 -0
  112. package/src/components/shared/dev-section/dev-section-page.tsx +72 -0
  113. package/src/components/shared/dev-section/dev-section-view.tsx +129 -0
  114. package/src/components/shared/dev-section/index.ts +2 -0
  115. package/src/components/shared/legal-document/index.ts +19 -0
  116. package/src/components/shared/legal-document/legal-document-page.tsx +178 -0
  117. package/src/components/shared/legal-document/use-legal-docs.ts +123 -0
  118. package/src/components/shared/product-release/index.ts +14 -3
  119. package/src/components/shared/product-release/release-detail-page.tsx +45 -7
  120. package/src/components/shared/roadmap/index.ts +23 -0
  121. package/src/components/shared/roadmap/roadmap-grid-skeleton.tsx +74 -0
  122. package/src/components/shared/roadmap/roadmap-grid.tsx +106 -0
  123. package/src/components/shared/roadmap/use-roadmap-voting.ts +163 -0
  124. package/src/components/ui/release-changelog-section.tsx +113 -32
  125. package/src/stories/EmbeddableChat.stories.tsx +186 -0
  126. package/src/types/delivery.ts +54 -0
  127. package/src/types/index.ts +1 -0
  128. package/src/utils/dev-sections/index.ts +17 -0
  129. package/src/utils/dev-sections/openframe-dev-sections.ts +148 -0
  130. package/src/utils/index.ts +6 -1
  131. package/dist/chunk-APM6KBPU.cjs.map +0 -1
  132. package/dist/chunk-Z3GQGR5E.js.map +0 -1
@@ -0,0 +1,174 @@
1
+ 'use client';
2
+
3
+ import { StatusBadge } from '../../ui';
4
+ import { getStatusColorScheme } from '../../../utils';
5
+ import {
6
+ type DeliveryItem,
7
+ TASK_TYPE_LABELS,
8
+ TASK_TYPE_TEXT_COLORS,
9
+ } from '../../../types/delivery';
10
+
11
+ interface DeliveryTableProps {
12
+ items: DeliveryItem[];
13
+ isLoading?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Format relative time for display
18
+ */
19
+ function getRelativeTime(timestamp: number): string {
20
+ const now = Date.now();
21
+ const diff = now - timestamp;
22
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
23
+ const weeks = Math.floor(days / 7);
24
+ const months = Math.floor(days / 30);
25
+
26
+ if (months > 0) {
27
+ return months === 1 ? 'last month' : `${months} months ago`;
28
+ }
29
+ if (weeks > 0) {
30
+ return weeks === 1 ? 'last week' : `${weeks} weeks ago`;
31
+ }
32
+ if (days > 0) {
33
+ return days === 1 ? 'yesterday' : `${days} days ago`;
34
+ }
35
+ return 'today';
36
+ }
37
+
38
+ /**
39
+ * Skeleton loader for rows - matching responsive structure
40
+ */
41
+ function SkeletonRow() {
42
+ return (
43
+ <div className="border-b border-ods-border last:border-b-0 p-[12px] md:p-[16px]">
44
+ <div className="flex flex-col md:flex-row items-start justify-between gap-[12px] md:gap-[16px] w-full">
45
+ {/* Left: Title, subtitle, and description skeleton */}
46
+ <div className="flex-1 min-w-0 w-full md:w-auto flex flex-col gap-[12px] md:gap-[16px]">
47
+ {/* Title skeleton - responsive */}
48
+ <div className="min-h-[24px] flex items-center">
49
+ <div className="h-[20px] bg-ods-border rounded animate-pulse w-full"></div>
50
+ </div>
51
+ {/* Subtitle skeleton - 1 line */}
52
+ <div className="min-h-[20px] flex items-center">
53
+ <div className="h-[20px] bg-ods-border rounded animate-pulse w-1/2"></div>
54
+ </div>
55
+ {/* Description skeleton - 3 lines */}
56
+ <div className="min-h-[72px] flex items-center">
57
+ <div className="flex-1 space-y-1">
58
+ <div className="h-[20px] bg-ods-border rounded animate-pulse w-full"></div>
59
+ <div className="h-[20px] bg-ods-border rounded animate-pulse w-full"></div>
60
+ <div className="h-[20px] bg-ods-border rounded animate-pulse w-2/3"></div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+
65
+ {/* Right: Badge skeleton - two stacked badges */}
66
+ <div className="flex-shrink-0 self-start flex flex-col gap-2">
67
+ <div className="h-[32px] w-[100px] bg-ods-border rounded animate-pulse"></div>
68
+ <div className="h-[32px] w-[120px] bg-ods-border rounded animate-pulse"></div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * DeliveryTable Component
77
+ * Displays bug fixes and enhancements with fixed-height rows
78
+ */
79
+ export function DeliveryTable({ items, isLoading = false }: DeliveryTableProps) {
80
+ // Show skeletons while loading
81
+ if (isLoading) {
82
+ return (
83
+ <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full">
84
+ <div className="w-full">
85
+ {[1, 2, 3, 4, 5].map((i) => (
86
+ <SkeletonRow key={i} />
87
+ ))}
88
+ </div>
89
+ </div>
90
+ );
91
+ }
92
+
93
+ // Empty state
94
+ if (items.length === 0) {
95
+ return (
96
+ <div className="bg-ods-card border border-ods-border rounded-[6px] p-[40px] text-center w-full">
97
+ <p className="text-ods-text-secondary text-[14px] font-['DM_Sans'] font-medium">
98
+ No tasks available
99
+ </p>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ return (
105
+ <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full">
106
+ <div className="w-full">
107
+ {items.map((item, index) => {
108
+ // Get task type badge label and text color
109
+ const taskType = item.taskType as keyof typeof TASK_TYPE_LABELS;
110
+ const typeBadgeLabel = TASK_TYPE_LABELS[taskType] || 'TASK';
111
+ const typeBadgeTextColor = TASK_TYPE_TEXT_COLORS[taskType] || '';
112
+
113
+ // Get status badge color scheme using centralized utility
114
+ const statusBadgeScheme = getStatusColorScheme(item.status);
115
+
116
+ // Calculate relative time from last activity (dateUpdated)
117
+ const relativeTime = getRelativeTime(item.dateUpdated);
118
+
119
+ return (
120
+ <div
121
+ key={item.id}
122
+ className={`border-b border-ods-border last:border-b-0 p-[12px] md:p-[16px] ${index === 0 ? '' : ''}`}
123
+ >
124
+ <div className="flex flex-col md:flex-row items-start justify-between gap-[12px] md:gap-[16px] w-full">
125
+ {/* Left: Title, subtitle, and description - matching roadmap-card.tsx structure */}
126
+ <div className="flex-1 min-w-0 w-full md:w-auto flex flex-col gap-[12px] md:gap-[16px]">
127
+ {/* Title: 2 lines on mobile, 1 line on desktop */}
128
+ <div className="min-h-[24px] md:min-h-[24px] flex items-center">
129
+ <h3 className="text-h3 text-ods-text-primary tracking-[-0.36px] flex-1 line-clamp-2 md:truncate break-words">
130
+ {item.title}
131
+ </h3>
132
+ </div>
133
+
134
+ {/* Subtitle: 1 line with last activity date, list name(s), task ID - Azeret Mono.
135
+ A task can live in multiple ClickUp lists ("Tasks in Multiple Lists" feature) —
136
+ we render every list joined by ", ". */}
137
+ <div className="min-h-[20px] flex items-center">
138
+ <p className="text-h5 text-ods-text-secondary uppercase tracking-[-0.28px] truncate">
139
+ ACTIVE {relativeTime}{item.listNames.length > 0 ? `, ${item.listNames.join(', ')}` : ''}, {item.id}
140
+ </p>
141
+ </div>
142
+
143
+ {/* Description: 3 lines max, 72px height with vertical centering - matching roadmap */}
144
+ <div className="min-h-[72px] flex items-center">
145
+ <p className="text-h4 text-ods-text-secondary line-clamp-3 break-words">
146
+ {item.description || 'No description provided'}
147
+ </p>
148
+ </div>
149
+ </div>
150
+
151
+ {/* Right: Status and Type badges stacked vertically */}
152
+ <div className="flex-shrink-0 self-start flex flex-col gap-2">
153
+ {/* Status Badge - matching roadmap cards */}
154
+ <StatusBadge
155
+ text={item.status.toUpperCase()}
156
+ colorScheme={statusBadgeScheme}
157
+ variant="card"
158
+ className="border border-ods-border"
159
+ />
160
+ {/* Task Type Badge - same style as Version badge in roadmap */}
161
+ <StatusBadge
162
+ text={typeBadgeLabel}
163
+ variant="card"
164
+ className={`border border-ods-border ${typeBadgeTextColor}`}
165
+ />
166
+ </div>
167
+ </div>
168
+ </div>
169
+ );
170
+ })}
171
+ </div>
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,9 @@
1
+ // NOTE: `DeliverySection` (standalone unfiltered fetch) was deleted —
2
+ // it had zero consumers (hub's release-detail wraps `<DeliveryTable>`
3
+ // directly, the public bug-fixes-and-enhancements page uses
4
+ // `<DeliveryLists>` with URL-driven filters) and duplicated the
5
+ // fetch/loading state machine. If an embedder ever needs a no-filter
6
+ // variant, build it as `<DeliveryLists ignoreUrlFilters />` rather
7
+ // than reviving a separate component.
8
+ export { DeliveryLists, type DeliveryListsProps } from './delivery-lists';
9
+ export { DeliveryTable } from './delivery-table';
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * DevSectionPage — full-page wrapper for a dev-center section
5
+ * (`/roadmap`, `/bug-fixes-and-enhancements`, `/releases`).
6
+ *
7
+ * Mounts the lib's canonical `PageLayout` directly (no in-app wrapper)
8
+ * so the back-button affordance stays in lockstep with whatever the
9
+ * design system ships — any future lib change to BackButton / TitleBlock
10
+ * propagates automatically.
11
+ *
12
+ * Composition: `PageShell` → `PageLayout` (back-to-home wired) →
13
+ * `DevSectionView` (icon hero + search + filter pills) → list body.
14
+ *
15
+ * Adding a new section is one entry in `OPENFRAME_DEV_SECTIONS` plus a
16
+ * single-line page file mounting this factory with the new key.
17
+ */
18
+
19
+ import type { ReactNode } from 'react';
20
+ import { useRouter } from '../../../embed-shims/next-navigation';
21
+ import { PageShell, PageLayout } from '../../ui';
22
+ import { DevSectionView } from './dev-section-view';
23
+ import {
24
+ OPENFRAME_DEV_SECTIONS,
25
+ type OpenframeDevSectionKey,
26
+ } from '../../../utils/dev-sections/openframe-dev-sections';
27
+
28
+ const SECTION_HERO_ICON_CLASS = 'h-10 w-10 text-ods-accent';
29
+
30
+ export interface DevSectionPageProps {
31
+ sectionKey: OpenframeDevSectionKey;
32
+ /** The page-specific list body (e.g. `<RoadmapList />`). */
33
+ children: ReactNode;
34
+ /** Back-button config — same shape as `LegalDocumentPage` /
35
+ * `ReleaseDetailPage`. Pass `false` to hide. Default
36
+ * `{ label: 'Back to home', href: '/' }`. */
37
+ backButton?: { label?: string; href?: string } | false;
38
+ }
39
+
40
+ export function DevSectionPage({ sectionKey, children, backButton }: DevSectionPageProps) {
41
+ const router = useRouter();
42
+ const section = OPENFRAME_DEV_SECTIONS[sectionKey];
43
+ const Icon = section.icon;
44
+
45
+ // Back-button config — mirrors LegalDocumentPage / ReleaseDetailPage.
46
+ // Default: { label: 'Back to home', href: '/' }. Pass `false` to hide.
47
+ // After `backButton &&` narrowing, inner type is `{ label?, href? } |
48
+ // undefined`; don't re-compare to `false` (TS2367).
49
+ const backCfg =
50
+ backButton === false
51
+ ? undefined
52
+ : {
53
+ label: (backButton ? backButton.label : undefined) ?? 'Back to home',
54
+ onClick: () => router.push((backButton ? backButton.href : undefined) ?? '/'),
55
+ };
56
+
57
+ return (
58
+ <PageShell>
59
+ <PageLayout backButton={backCfg}>
60
+ <DevSectionView
61
+ sectionKey={sectionKey}
62
+ hero={{
63
+ icon: <Icon className={SECTION_HERO_ICON_CLASS} />,
64
+ description: section.hero.description,
65
+ }}
66
+ >
67
+ {children}
68
+ </DevSectionView>
69
+ </PageLayout>
70
+ </PageShell>
71
+ );
72
+ }
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * DevSectionView — the canonical chrome for ANY dev-center section
5
+ * (Roadmap / Delivery / Releases). One component, used in BOTH:
6
+ *
7
+ * - tabbed `/roadmap-and-releases` (compact title mode, no `hero`)
8
+ * - full-page `/roadmap`, `/bug-fixes-and-enhancements`, `/releases`
9
+ * (hero mode with icon + description + back link)
10
+ *
11
+ * Owns: title rendering, the inline search input, the filter pill row,
12
+ * and the URL-param wiring that connects both. The list `children`
13
+ * receive a clean URL contract — they read `?<paramKey>=...` via
14
+ * `useSearchParams()` and refetch on change. No duplicated controls.
15
+ */
16
+
17
+ import type { ReactNode } from 'react';
18
+ import { useState, useEffect } from 'react';
19
+ import { useRouter, useSearchParams, usePathname } from '../../../embed-shims';
20
+ import { SearchInput } from '../../ui';
21
+ import { StatusFilterComponent } from '../../features';
22
+ import {
23
+ OPENFRAME_DEV_SECTIONS,
24
+ type OpenframeDevSectionKey,
25
+ } from '../../../utils/dev-sections/openframe-dev-sections';
26
+
27
+ export interface DevSectionViewProps {
28
+ /** Which section to render — drives title, search, and filter
29
+ * config via the `OPENFRAME_DEV_SECTIONS` registry. */
30
+ sectionKey: OpenframeDevSectionKey;
31
+ /** When set, renders the rich page-level hero (icon + h1 + description).
32
+ * Omit for the compact tab-context heading. */
33
+ hero?: {
34
+ /** Pre-rendered icon JSX. Server components render the icon themselves
35
+ * and pass the element here — function references can't cross the
36
+ * server→client boundary, but React elements can. */
37
+ icon: ReactNode;
38
+ description: string;
39
+ };
40
+ /** The page-specific list body. Reads URL params written by this
41
+ * component (search input + filter pills). */
42
+ children: ReactNode;
43
+ }
44
+
45
+ export function DevSectionView({ sectionKey, hero, children }: DevSectionViewProps) {
46
+ const section = OPENFRAME_DEV_SECTIONS[sectionKey];
47
+ const router = useRouter();
48
+ const pathname = usePathname();
49
+ const searchParams = useSearchParams();
50
+
51
+ const search = section.search;
52
+ const filter = section.filter;
53
+
54
+ const currentSearch = search ? searchParams.get(search.paramKey) || '' : '';
55
+ const currentFilterValue = filter
56
+ ? searchParams.get(filter.paramKey) || filter.defaultValue
57
+ : '';
58
+
59
+ // Controlled search-input state — input commits to the URL only on
60
+ // Enter (not on every keystroke), preserving the legacy behavior.
61
+ // Lazy init from URL avoids a brief flash of stale value on first
62
+ // paint after URL-driven re-render (e.g. tab switch).
63
+ const [searchValue, setSearchValue] = useState(() => currentSearch);
64
+ useEffect(() => {
65
+ setSearchValue(currentSearch);
66
+ }, [currentSearch]);
67
+
68
+ const handleSearchSubmit = (value: string) => {
69
+ if (!search) return;
70
+ const params = new URLSearchParams(searchParams.toString());
71
+ if (value.trim()) params.set(search.paramKey, value.trim());
72
+ else params.delete(search.paramKey);
73
+ router.replace(`${pathname}?${params.toString()}`, { scroll: false });
74
+ };
75
+
76
+ const handleFilterChange = (value: string) => {
77
+ if (!filter) return;
78
+ const params = new URLSearchParams(searchParams.toString());
79
+ if (value === filter.defaultValue) params.delete(filter.paramKey);
80
+ else params.set(filter.paramKey, value);
81
+ router.replace(`${pathname}?${params.toString()}`, { scroll: false });
82
+ };
83
+
84
+ return (
85
+ <div className="w-full flex flex-col gap-10">
86
+ {hero ? (
87
+ <div className="space-y-4">
88
+ <h1 className="text-h1 tracking-[-1.12px] text-ods-text-primary flex items-center gap-3">
89
+ {hero.icon}
90
+ {section.hero.title}
91
+ </h1>
92
+ <p className="font-['DM_Sans'] font-medium text-[18px] leading-[28px] text-ods-text-secondary max-w-3xl">
93
+ {hero.description}
94
+ </p>
95
+ </div>
96
+ ) : (
97
+ <div className="flex items-center justify-between w-full">
98
+ <h2 className="font-['Azeret_Mono'] font-semibold text-[32px] md:text-[40px] lg:text-[48px] leading-[40px] md:leading-[48px] lg:leading-[56px] text-ods-text-primary tracking-[-0.64px] md:tracking-[-0.8px] lg:tracking-[-0.96px]">
99
+ {section.hero.title}
100
+ <span className="text-ods-accent">:</span>
101
+ </h2>
102
+ </div>
103
+ )}
104
+
105
+ {(search || filter) && (
106
+ <div className="space-y-4">
107
+ {search && (
108
+ <SearchInput
109
+ showDropdown={false}
110
+ placeholder={search.placeholder}
111
+ value={searchValue}
112
+ onChange={setSearchValue}
113
+ onSubmit={handleSearchSubmit}
114
+ />
115
+ )}
116
+ {filter && (
117
+ <StatusFilterComponent
118
+ selectedStatus={currentFilterValue}
119
+ onStatusChange={handleFilterChange}
120
+ statusOptions={[...filter.options]}
121
+ />
122
+ )}
123
+ </div>
124
+ )}
125
+
126
+ {children}
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,2 @@
1
+ export { DevSectionView, type DevSectionViewProps } from './dev-section-view';
2
+ export { DevSectionPage, type DevSectionPageProps } from './dev-section-page';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared legal-document surface barrel.
3
+ *
4
+ * Exports one parameterized `<LegalDocumentPage>` that replaces hub's
5
+ * formerly-duplicated `PrivacyPolicyPage` + `TermsOfServicePage`
6
+ * (95% identical, differing only in copy strings).
7
+ */
8
+
9
+ export {
10
+ LegalDocumentPage,
11
+ type LegalDocumentPageProps,
12
+ type LegalDocumentMarkdownRendererProps,
13
+ } from './legal-document-page';
14
+ export {
15
+ useLegalDocs,
16
+ type LegalDocument,
17
+ type UseLegalDocsOptions,
18
+ type UseLegalDocsReturn,
19
+ } from './use-legal-docs';
@@ -0,0 +1,178 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * LegalDocumentPage — unified UI for privacy-policy, terms-of-service,
5
+ * and any other markdown-backed legal document.
6
+ *
7
+ * Replaces two near-identical hub components (`PrivacyPolicyPage` +
8
+ * `TermsOfServicePage`) that differed only in title, contact email,
9
+ * and copy strings. Caller passes those as props.
10
+ *
11
+ * Markdown rendering: defaults to lib's `SimpleMarkdownRenderer`
12
+ * (sufficient for plain-markdown legal docs). Embedders that need
13
+ * richer markdown (embeds, video, OG previews) pass their own via
14
+ * the `MarkdownRenderer` prop — same injection pattern as
15
+ * `ReleaseDetailPage`.
16
+ *
17
+ * Endpoint configuration: forwarded to `useLegalDocs(docType, { apiEndpoint })`.
18
+ */
19
+
20
+ import type { ComponentType } from 'react';
21
+ import { PageShell, PageLayout } from '../../ui';
22
+ import { SimpleMarkdownRenderer } from '../../ui/simple-markdown-renderer';
23
+ import { useRouter } from '../../../embed-shims/next-navigation';
24
+ import { useLegalDocs, type LegalDocument } from './use-legal-docs';
25
+ import { formatLegalDate } from '../../../utils/format';
26
+
27
+ export interface LegalDocumentMarkdownRendererProps {
28
+ content: string;
29
+ sectionIds?: Array<{ id: string; title: string; level: number }>;
30
+ demoteMarkdownH1ToH2?: boolean;
31
+ }
32
+
33
+ export interface LegalDocumentPageProps {
34
+ /** Document type identifier — drives the default API endpoint
35
+ * `/api/legal/<docType>` AND the error-log prefix. Common values:
36
+ * `'privacy'`, `'terms'`. Embedders may use any string. */
37
+ docType: string;
38
+ /** Heading text (e.g. "Privacy Policy", "Terms of Service"). */
39
+ title: string;
40
+ /** Fallback subtitle shown when no `lastUpdated` date is available
41
+ * (e.g. "Our privacy policy and data protection practices"). */
42
+ fallbackDescription: string;
43
+ /** Email shown in the error + empty-state copy
44
+ * (e.g. `'privacy@openframe.io'`, `'legal@openframe.io'`). */
45
+ contactEmail: string;
46
+ /** Prompt shown above the contact link in the error state
47
+ * (e.g. "For privacy-related questions, please contact:"). */
48
+ errorContactPrompt: string;
49
+ /** Title for the error block (e.g. "Unable to load privacy policy"). */
50
+ errorTitle: string;
51
+ /** Sentence shown when the API returns no document
52
+ * (e.g. "Privacy policy content is not available at this time."). */
53
+ emptyStateMessage: string;
54
+ /** SSR-prepared document, if available. */
55
+ initialData?: LegalDocument | null;
56
+ /** SSR-prepared formatted "Last Updated" label. Stable across hydration. */
57
+ initialLastUpdatedLabel?: string | null;
58
+ /** Override the default `/api/legal/<docType>` endpoint
59
+ * (reverse-proxy embedders, alternate API paths). */
60
+ apiEndpoint?: string;
61
+ /** Override the default markdown renderer. */
62
+ MarkdownRenderer?: ComponentType<LegalDocumentMarkdownRendererProps>;
63
+ /** Back-button config — same pattern as `DevSectionPage`. Pass `false`
64
+ * to hide. Default `{ label: 'Back to home', href: '/' }`. */
65
+ backButton?: { label?: string; href?: string } | false;
66
+ }
67
+
68
+ export function LegalDocumentPage({
69
+ docType,
70
+ title,
71
+ fallbackDescription,
72
+ contactEmail,
73
+ errorContactPrompt,
74
+ errorTitle,
75
+ emptyStateMessage,
76
+ initialData = null,
77
+ initialLastUpdatedLabel = null,
78
+ apiEndpoint,
79
+ MarkdownRenderer = SimpleMarkdownRenderer,
80
+ backButton,
81
+ }: LegalDocumentPageProps) {
82
+ const router = useRouter();
83
+ const { data, isLoading, error } = useLegalDocs(docType, { initialData, apiEndpoint });
84
+
85
+ // Back-button config — mirrors DevSectionPage's `{ label: 'Back to home',
86
+ // onClick: () => router.push('/') }`. Hide entirely when caller passes
87
+ // `false` (e.g. embed-mode where the host owns navigation chrome).
88
+ const backCfg =
89
+ backButton === false
90
+ ? undefined
91
+ : {
92
+ label: backButton?.label ?? 'Back to home',
93
+ onClick: () => router.push(backButton?.href ?? '/'),
94
+ };
95
+
96
+ const fallbackLastUpdatedLabel =
97
+ data?.lastSynced != null ? formatLegalDate(data.lastSynced) : null;
98
+ const effectiveLastUpdatedLabel = initialLastUpdatedLabel ?? fallbackLastUpdatedLabel;
99
+
100
+ // Title with accent-colon trailing dot — matches knowledge-hub typography
101
+ const customTitle = (
102
+ <div className="flex flex-col gap-4">
103
+ <h1 className="font-['Azeret_Mono'] text-[32px] md:text-[40px] lg:text-[48px] font-semibold leading-[1em] tracking-[-0.02em] text-ods-text-primary">
104
+ <span>{title}</span>
105
+ <span className="text-ods-accent">.</span>
106
+ </h1>
107
+ <p className="font-['DM_Sans'] text-base md:text-lg text-ods-text-secondary max-w-2xl">
108
+ {effectiveLastUpdatedLabel ? `Last Updated: ${effectiveLastUpdatedLabel}` : fallbackDescription}
109
+ {data?.sourceFile && (
110
+ <span className="block text-sm mt-1 opacity-75">Source: {data.sourceFile}</span>
111
+ )}
112
+ </p>
113
+ </div>
114
+ );
115
+
116
+ return (
117
+ <PageShell>
118
+ <PageLayout backButton={backCfg}>
119
+ <div className="flex flex-col gap-4">{customTitle}</div>
120
+
121
+ <div className="flex flex-col lg:flex-row gap-6 lg:gap-10 items-start flex-1">
122
+ <div className="flex-1">
123
+ <div className="w-full">
124
+ <article className="space-y-2">
125
+ {isLoading ? (
126
+ // Loading skeleton matching Knowledge Hub pattern
127
+ <div className="space-y-6">
128
+ <div className="h-10 bg-ods-skeleton rounded-lg w-3/4 animate-pulse"></div>
129
+ <div className="space-y-4">
130
+ <div className="h-4 bg-ods-skeleton rounded w-full animate-pulse"></div>
131
+ <div className="h-4 bg-ods-skeleton rounded w-full animate-pulse"></div>
132
+ <div className="h-4 bg-ods-skeleton rounded w-5/6 animate-pulse"></div>
133
+ </div>
134
+ <div className="h-32 bg-ods-card border border-ods-border rounded-lg animate-pulse"></div>
135
+ <div className="space-y-4">
136
+ <div className="h-4 bg-ods-skeleton rounded w-full animate-pulse"></div>
137
+ <div className="h-4 bg-ods-skeleton rounded w-4/5 animate-pulse"></div>
138
+ </div>
139
+ </div>
140
+ ) : error ? (
141
+ <div className="text-center space-y-4">
142
+ <div className="bg-red-900/20 border border-red-700 rounded-lg p-6">
143
+ <p className="text-red-400 mb-2">{errorTitle}</p>
144
+ <p className="text-red-300 text-sm">{error}</p>
145
+ </div>
146
+ <div className="text-ods-text-secondary">
147
+ <p>{errorContactPrompt}</p>
148
+ <a href={`mailto:${contactEmail}`} className="text-ods-accent hover:underline">
149
+ {contactEmail}
150
+ </a>
151
+ </div>
152
+ </div>
153
+ ) : data ? (
154
+ <MarkdownRenderer
155
+ content={data.content}
156
+ sectionIds={data.sections || []}
157
+ demoteMarkdownH1ToH2
158
+ />
159
+ ) : (
160
+ <div className="text-center text-ods-text-secondary py-16">
161
+ <p className="text-xl">{emptyStateMessage}</p>
162
+ <p className="mt-2">
163
+ Please contact{' '}
164
+ <a href={`mailto:${contactEmail}`} className="text-ods-accent hover:underline">
165
+ {contactEmail}
166
+ </a>{' '}
167
+ for more information.
168
+ </p>
169
+ </div>
170
+ )}
171
+ </article>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </PageLayout>
176
+ </PageShell>
177
+ );
178
+ }