@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,123 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useLegalDocs — fetches a legal document (privacy policy, terms of
5
+ * service, or any other markdown-backed legal page) from a hub API.
6
+ *
7
+ * Endpoint configuration — `apiEndpoint`:
8
+ * Default `/api/legal/<docType>`. Reverse-proxy embedders override
9
+ * with their proxied path (e.g. `/proxy/legal/privacy`).
10
+ *
11
+ * Data shape mirrors the hub's `lib/data/legal-utils.ts:LegalDocument`
12
+ * server type. The hook intentionally re-declares the type here so
13
+ * lib consumers don't need to import a server-side type.
14
+ */
15
+
16
+ import { useState, useEffect, useCallback } from 'react';
17
+
18
+ export interface LegalDocument {
19
+ title: string;
20
+ content: string;
21
+ sourceFile: string;
22
+ lastSynced: string | null;
23
+ githubSha: string | null;
24
+ sections: Array<{ id: string; title: string; level: number }>;
25
+ docType: string;
26
+ meta: {
27
+ sectionsCount: number;
28
+ contentLength: number;
29
+ lastSyncedAgo: string;
30
+ };
31
+ }
32
+
33
+ export interface UseLegalDocsReturn {
34
+ data: LegalDocument | null;
35
+ isLoading: boolean;
36
+ error: string | null;
37
+ refetch: () => void;
38
+ }
39
+
40
+ export interface UseLegalDocsOptions {
41
+ /** Optional pre-fetched payload from server (SSR / RSC). When set,
42
+ * the hook skips the initial client fetch. */
43
+ initialData?: LegalDocument | null;
44
+ /** Full GET endpoint URL. Default `/api/legal/<docType>`. */
45
+ apiEndpoint?: string;
46
+ }
47
+
48
+ /**
49
+ * Hook to fetch a legal document.
50
+ * @param docType — short identifier for the document (drives the
51
+ * default endpoint path AND the error-log prefix). Common values:
52
+ * `'privacy'` (SECURITY.md), `'terms'` (LICENSE). Embedders may use
53
+ * any string — the hook treats it as opaque.
54
+ */
55
+ export function useLegalDocs(
56
+ docType: string,
57
+ options: UseLegalDocsOptions = {}
58
+ ): UseLegalDocsReturn {
59
+ const { initialData = null, apiEndpoint } = options;
60
+ const effectiveEndpoint = apiEndpoint ?? `/api/legal/${docType}`;
61
+
62
+ const [data, setData] = useState<LegalDocument | null>(initialData ?? null);
63
+ const [isLoading, setIsLoading] = useState(!initialData);
64
+ const [error, setError] = useState<string | null>(null);
65
+
66
+ const fetchDocument = useCallback(async () => {
67
+ try {
68
+ setIsLoading(true);
69
+ setError(null);
70
+
71
+ const response = await fetch(effectiveEndpoint);
72
+
73
+ if (!response.ok) {
74
+ throw new Error(
75
+ `Failed to fetch ${docType} document: ${response.status} ${response.statusText}`
76
+ );
77
+ }
78
+
79
+ const result = await response.json();
80
+
81
+ // Validate the response has required fields
82
+ if (!result.content) {
83
+ throw new Error(`${docType} document content is empty`);
84
+ }
85
+
86
+ setData(result);
87
+ } catch (err) {
88
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
89
+ console.error(`Error fetching ${docType} document:`, err);
90
+ setError(errorMessage);
91
+ } finally {
92
+ setIsLoading(false);
93
+ }
94
+ }, [docType, effectiveEndpoint]);
95
+
96
+ // Reset cached data when docType changes — otherwise an embedder using
97
+ // the same hook instance for sequential docTypes (privacy → terms)
98
+ // would briefly render the OLD doc's content while the new fetch is
99
+ // in-flight. Not currently triggered by hub's per-route SSR (each
100
+ // docType mounts in a fresh component), but enforces the contract.
101
+ useEffect(() => {
102
+ setData(initialData ?? null);
103
+ setError(null);
104
+ setIsLoading(!initialData);
105
+ }, [docType, initialData]);
106
+
107
+ // Fetch on mount (only if we don't already have server-provided initialData)
108
+ useEffect(() => {
109
+ if (initialData) return;
110
+ fetchDocument();
111
+ }, [fetchDocument, initialData]);
112
+
113
+ const refetch = () => {
114
+ fetchDocument();
115
+ };
116
+
117
+ return {
118
+ data,
119
+ isLoading,
120
+ error,
121
+ refetch,
122
+ };
123
+ }
@@ -8,8 +8,19 @@ export {
8
8
  type VideoDisplaySectionProps,
9
9
  type MarkdownRendererProps,
10
10
  type RoadmapItem,
11
- type DeliveryResponse,
12
- type RoadmapSectionProps,
13
- type DeliverySectionProps
14
11
  } from './release-detail-page'
12
+ // NOTE: `RoadmapSectionProps` / `DeliverySectionProps` (the injectable-
13
+ // component slot types for ReleaseDetailPage) are intentionally NOT
14
+ // re-exported from this barrel — they collide with the prop types of
15
+ // the concrete `<RoadmapGrid>` / `<DeliverySection>` components in
16
+ // `./shared/{roadmap,delivery}` (TS2308 ambiguous re-export at the
17
+ // top-level `components/index.ts` barrel). The slot types remain
18
+ // internal to `release-detail-page.tsx`; consumers needing them can
19
+ // import directly from
20
+ // `@flamingo-stack/openframe-frontend-core/components/shared/product-release/release-detail-page`.
21
+ // DeliveryResponse re-sourced from the canonical types module so the
22
+ // public deep-import path `@flamingo-stack/openframe-frontend-core/components`
23
+ // keeps resolving (hub's components/releases/release-detail-page.tsx
24
+ // imports it through this barrel).
25
+ export type { DeliveryResponse } from '../../../types/delivery'
15
26
  export { ReleaseDetailSkeleton } from './release-detail-skeleton'
@@ -2,8 +2,10 @@
2
2
 
3
3
  import { useState, useEffect, ComponentType } from 'react';
4
4
  import Link from '../../../embed-shims/next-link';
5
+ import { useRouter } from '../../../embed-shims/next-navigation';
5
6
  import { Card, CardContent } from '../../ui/card';
6
7
  import { ArticleDetailLayout } from '../../layout/article-detail-layout';
8
+ import { BackButton } from '../../layout/back-button';
7
9
  import { ReleaseChangelogSection } from '../../ui/release-changelog-section';
8
10
  import { StatusBadge } from '../../ui/status-badge';
9
11
  import { SquareAvatar } from '../../ui/square-avatar';
@@ -29,13 +31,15 @@ export interface MarkdownRendererProps {
29
31
  // shape once the entities barrel was added; re-exporting the canonical
30
32
  // type fixes the collision while keeping the same import path for
31
33
  // downstream consumers of `./release-detail-page`.
32
- export type { RoadmapItem } from '../../chat/types/entities/roadmap-item';
33
34
  import type { RoadmapItem } from '../../chat/types/entities/roadmap-item';
34
-
35
- export interface DeliveryResponse {
36
- completed: unknown[];
37
- inProgress: unknown[];
38
- }
35
+ import type { DeliveryResponse } from '../../../types/delivery';
36
+ // Re-export both types for source-compat with consumers importing
37
+ // through this module. Canonical sources:
38
+ // - RoadmapItem `../../chat/types/entities/roadmap-item`
39
+ // - DeliveryResponse → `../../../types/delivery` (single source of
40
+ // truth, shared with the lib `<DeliveryLists>` / `<DeliveryTable>`
41
+ // components and the new types barrel).
42
+ export type { RoadmapItem, DeliveryResponse };
39
43
 
40
44
  export interface RoadmapSectionProps {
41
45
  items: RoadmapItem[];
@@ -91,6 +95,10 @@ export interface ReleaseDetailPageProps {
91
95
  // API endpoints for fetching linked tasks
92
96
  roadmapApiEndpoint?: string;
93
97
  deliveryApiEndpoint?: string;
98
+ /** Back-button config — same pattern as `DevSectionPage` /
99
+ * `LegalDocumentPage`. Pass `false` to hide. Default
100
+ * `{ label: 'Back to home', href: '/' }`. */
101
+ backButton?: { label?: string; href?: string } | false;
94
102
  }
95
103
 
96
104
  // Default simple markdown renderer (just renders as text)
@@ -108,14 +116,26 @@ export function ReleaseDetailPage({
108
116
  VideoSection,
109
117
  VideoDisplaySection,
110
118
  roadmapApiEndpoint = '/api/roadmap',
111
- deliveryApiEndpoint = '/api/delivery'
119
+ deliveryApiEndpoint = '/api/delivery',
120
+ backButton
112
121
  }: ReleaseDetailPageProps) {
122
+ const router = useRouter();
113
123
  // Use pre-fetched data if provided (admin preview), otherwise fetch via hook (public)
114
124
  const { data: fetchedRelease, error, isLoading } = useRelease(initialData ? undefined : slug);
115
125
  const release = (initialData || fetchedRelease) as Record<string, unknown> | undefined;
116
126
  const [galleryOpen, setGalleryOpen] = useState(false);
117
127
  const [galleryIndex, setGalleryIndex] = useState(0);
118
128
 
129
+ // Back-button config — mirrors DevSectionPage / LegalDocumentPage.
130
+ // Default: { label: 'Back to home', href: '/' }. Pass `false` to hide
131
+ // (e.g. embed-mode where the host owns navigation chrome).
132
+ // Narrowing note: `backButton &&` already eliminates the `false` branch,
133
+ // so the inner expressions are typed as `{ label?, href? } | undefined`.
134
+ // Don't re-compare to `false` here — tsc TS2367s on the dead branch.
135
+ const showBackButton = backButton !== false;
136
+ const backLabel = (backButton ? backButton.label : undefined) ?? 'Back to home';
137
+ const backHref = (backButton ? backButton.href : undefined) ?? '/';
138
+
119
139
  // Fetch roadmap and delivery tasks if linked to this release
120
140
  const [roadmapTasks, setRoadmapTasks] = useState<RoadmapItem[]>([]);
121
141
  const [deliveryData, setDeliveryData] = useState<DeliveryResponse | null>(null);
@@ -201,6 +221,15 @@ export function ReleaseDetailPage({
201
221
 
202
222
  return (
203
223
  <ArticleDetailLayout>
224
+ {/* Back button — desktop-only, matches DevSectionPage / LegalDocumentPage
225
+ (TitleBlock renders the same `hidden md:inline-flex` BackButton). */}
226
+ {showBackButton && (
227
+ <BackButton
228
+ label={backLabel}
229
+ onClick={() => router.push(backHref)}
230
+ className="hidden md:inline-flex mb-4"
231
+ />
232
+ )}
204
233
  <div className="space-y-6 md:space-y-8">
205
234
  {/* Title Block */}
206
235
  <div className="flex flex-col md:flex-row md:items-end gap-4 w-full">
@@ -399,22 +428,31 @@ export function ReleaseDetailPage({
399
428
  icon={<AlertTriangle className="h-6 w-6" />}
400
429
  SimpleMarkdownRenderer={MarkdownRenderer}
401
430
  />
431
+ {/* Features / Bugs / Improvements use `previewFirst` — same
432
+ progressive-disclosure pattern as the investor-update detail
433
+ page's Key Highlights / Financial Notes sections. Shows the
434
+ first entry in full + fade-masks the rest, with a "Show N
435
+ more / Show less" toggle. Breaking Changes (above) stays
436
+ fully expanded — it's critical info, not skim-friendly. */}
402
437
  <ReleaseChangelogSection
403
438
  title="Features Added"
404
439
  entries={featuresAdded || []}
405
440
  icon={<Sparkles className="h-6 w-6" />}
441
+ previewFirst
406
442
  SimpleMarkdownRenderer={MarkdownRenderer}
407
443
  />
408
444
  <ReleaseChangelogSection
409
445
  title="Bugs Fixed"
410
446
  entries={bugFixed || []}
411
447
  icon={<Wrench className="h-6 w-6" />}
448
+ previewFirst
412
449
  SimpleMarkdownRenderer={MarkdownRenderer}
413
450
  />
414
451
  <ReleaseChangelogSection
415
452
  title="Improvements"
416
453
  entries={improvements || []}
417
454
  icon={<TrendingUp className="h-6 w-6" />}
455
+ previewFirst
418
456
  SimpleMarkdownRenderer={MarkdownRenderer}
419
457
  />
420
458
 
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared roadmap surface barrel.
3
+ *
4
+ * IMPORTANT: This barrel MUST NOT re-export `RoadmapItem`. The canonical
5
+ * type is exported via two paths already:
6
+ * - `@flamingo-stack/openframe-frontend-core/components/chat`
7
+ * (the source of truth at `chat/types/entities/roadmap-item.ts`)
8
+ * - `@flamingo-stack/openframe-frontend-core/components` (via
9
+ * `./shared/product-release` re-export)
10
+ *
11
+ * Adding a third re-export path here would trigger TypeScript's TS2308
12
+ * ambiguous re-export warning. Consumers needing the type should import
13
+ * it from one of the two existing paths.
14
+ */
15
+
16
+ export { RoadmapGrid, type RoadmapGridProps } from './roadmap-grid';
17
+ export { RoadmapGridSkeleton, type RoadmapGridSkeletonProps } from './roadmap-grid-skeleton';
18
+ // `VoteType` deliberately NOT re-exported — `./chat` already exports
19
+ // the same-shape `VoteType` from `roadmap-card.tsx`; a duplicate path
20
+ // triggers TS2308 ambiguous re-export at the top-level
21
+ // `components/index.ts` barrel. Consumers can import the canonical
22
+ // `VoteType` from `@flamingo-stack/openframe-frontend-core/components/chat`.
23
+ export { useRoadmapVoting, type VoteState, type UseRoadmapVotingOptions } from './use-roadmap-voting';
@@ -0,0 +1,74 @@
1
+ /**
2
+ * RoadmapGridSkeleton — loading state for the `/roadmap` grid view.
3
+ *
4
+ * Pure JSX (no hooks, no events) — `'use client'` not strictly required
5
+ * here; tsup's client-entry banner injects it automatically when this
6
+ * file is bundled into the client output. We match the playbook's
7
+ * skeleton-file convention (no directive when no hooks).
8
+ *
9
+ * NOTE: lib's `chat/entity-cards/roadmap-card.tsx` also exports a
10
+ * `RoadmapCardSkeleton` — that one is the COMPACT 56px chat-card
11
+ * variant. This file's internal card-skeleton (340px grid card)
12
+ * intentionally stays file-internal to avoid the naming collision;
13
+ * only `RoadmapGridSkeleton` is exported.
14
+ */
15
+
16
+ function RoadmapCardSkeleton() {
17
+ return (
18
+ <div className="bg-ods-card border border-ods-border rounded-[6px] p-[24px] flex flex-col gap-[16px] min-h-[340px] relative">
19
+ {/* Status Badge Skeleton - Top Right */}
20
+ <div className="absolute top-[24px] right-[24px]">
21
+ <div className="h-[20px] w-[80px] bg-ods-border rounded animate-pulse"></div>
22
+ </div>
23
+
24
+ {/* Icon and title skeleton */}
25
+ <div className="flex items-center gap-[16px] pr-[120px]">
26
+ <div className="w-[80px] h-[80px] bg-ods-border rounded-lg flex-shrink-0 animate-pulse"></div>
27
+ <div className="flex-1 min-w-0 flex flex-col gap-1">
28
+ <div className="min-h-[48px] flex items-center">
29
+ <div className="h-[24px] w-full bg-ods-border rounded animate-pulse"></div>
30
+ </div>
31
+ <div className="min-h-[20px] flex items-center">
32
+ <div className="h-[14px] w-1/2 bg-ods-border rounded animate-pulse"></div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+
37
+ {/* Description skeleton - exactly 3 lines */}
38
+ <div className="min-h-[72px] flex items-center">
39
+ <div className="w-full space-y-2">
40
+ <div className="h-[24px] bg-ods-border rounded animate-pulse"></div>
41
+ <div className="h-[24px] bg-ods-border rounded animate-pulse"></div>
42
+ <div className="h-[24px] w-4/5 bg-ods-border rounded animate-pulse"></div>
43
+ </div>
44
+ </div>
45
+
46
+ <div className="flex-1"></div>
47
+
48
+ {/* Bottom skeleton */}
49
+ <div className="flex items-center justify-between">
50
+ <div className="h-[48px] w-[120px] bg-ods-border rounded animate-pulse"></div>
51
+ <div className="h-[32px] w-[100px] bg-ods-border rounded animate-pulse"></div>
52
+ </div>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export interface RoadmapGridSkeletonProps {
58
+ /** Number of skeleton cards to show. Default 4. */
59
+ count?: number;
60
+ /** Show the desktop left margin (~120px) that aligns the grid with
61
+ * the page hero's title block. Default `true`. Related-content rails
62
+ * inside narrower surfaces (e.g. the release detail page) pass `false`. */
63
+ showLeftMargin?: boolean;
64
+ }
65
+
66
+ export function RoadmapGridSkeleton({ count = 4, showLeftMargin = true }: RoadmapGridSkeletonProps) {
67
+ return (
68
+ <div className={`grid grid-cols-1 md:grid-cols-2 gap-6 ${showLeftMargin ? 'md:ml-[120px]' : ''}`}>
69
+ {Array.from({ length: count }).map((_, i) => (
70
+ <RoadmapCardSkeleton key={i} />
71
+ ))}
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * RoadmapGrid — full-page roadmap surface.
5
+ *
6
+ * Renders a responsive 2-col grid of `<RoadmapCard>`s (lib's chat-entity
7
+ * card, default density), threads voting state through `useRoadmapVoting`,
8
+ * and follows up a successful vote with a single-task refresh fetch so
9
+ * the displayed counts stay live.
10
+ *
11
+ * Endpoint configuration — `buildRefreshUrl`:
12
+ * The single-task refresh hits a PATH-based endpoint
13
+ * (`/api/roadmap/<taskId>` by default). A string-concat `refreshEndpoint`
14
+ * would silently break embedders whose by-id route is shaped
15
+ * differently (e.g. `/api/roadmap?id=…`), so this prop is a function
16
+ * builder. The default matches the hub's pre-migration shape.
17
+ *
18
+ * Empty state — uses lib's `<EmptyState>` directly (identical API to
19
+ * hub's, lives in `src/components/empty-state.tsx`).
20
+ */
21
+
22
+ import { useState } from 'react';
23
+ import { RoadmapCard } from '../../chat/entity-cards/roadmap-card';
24
+ import { useRoadmapVoting, type UseRoadmapVotingOptions } from './use-roadmap-voting';
25
+ import { EmptyState } from '../../empty-state';
26
+ import type { RoadmapItem } from '../../chat/types/entities/roadmap-item';
27
+
28
+ const DEFAULT_BUILD_REFRESH_URL = (taskId: string) => `/api/roadmap/${taskId}`;
29
+
30
+ export interface RoadmapGridProps {
31
+ items: RoadmapItem[];
32
+ onItemUpdate?: (updatedItem: RoadmapItem) => void;
33
+ /** Show the desktop left margin (~120px) that aligns the grid with
34
+ * the page hero. Default `true`. Related-content rails pass `false`. */
35
+ showLeftMargin?: boolean;
36
+ /** URL builder for the per-task refresh call after a successful vote.
37
+ * Function shape because the taskId sits in the URL path, not a
38
+ * query param. Default `(t) => \`/api/roadmap/${t}\``. */
39
+ buildRefreshUrl?: (taskId: string) => string;
40
+ /** Voting hook options (vote endpoint + storage key) — see
41
+ * `useRoadmapVoting`. */
42
+ votingOptions?: UseRoadmapVotingOptions;
43
+ }
44
+
45
+ export function RoadmapGrid({
46
+ items,
47
+ onItemUpdate,
48
+ showLeftMargin = true,
49
+ buildRefreshUrl = DEFAULT_BUILD_REFRESH_URL,
50
+ votingOptions,
51
+ }: RoadmapGridProps) {
52
+ const { getVote, toggleVote } = useRoadmapVoting(votingOptions);
53
+ const [votingTasks, setVotingTasks] = useState<Set<string>>(new Set());
54
+
55
+ const handleVote = async (taskId: string, voteType: 'up' | 'down') => {
56
+ // Prevent double-clicking
57
+ if (votingTasks.has(taskId)) return;
58
+
59
+ setVotingTasks(prev => new Set(prev).add(taskId));
60
+
61
+ try {
62
+ const result = await toggleVote(taskId, voteType);
63
+
64
+ if (result.success) {
65
+ // Refresh the specific task from server
66
+ const response = await fetch(buildRefreshUrl(taskId));
67
+ if (response.ok) {
68
+ const data = await response.json();
69
+ if (data.item && onItemUpdate) {
70
+ onItemUpdate(data.item);
71
+ }
72
+ }
73
+ }
74
+ } finally {
75
+ setVotingTasks(prev => {
76
+ const next = new Set(prev);
77
+ next.delete(taskId);
78
+ return next;
79
+ });
80
+ }
81
+ };
82
+
83
+ if (items.length === 0) {
84
+ return (
85
+ <EmptyState
86
+ type="generic"
87
+ title="No roadmap items"
88
+ description="Check back soon for upcoming features and improvements!"
89
+ />
90
+ );
91
+ }
92
+
93
+ return (
94
+ <div className={`grid grid-cols-1 md:grid-cols-2 gap-6 ${showLeftMargin ? 'md:ml-[120px]' : ''}`}>
95
+ {items.map((item) => (
96
+ <RoadmapCard
97
+ key={item.id}
98
+ item={item}
99
+ userVote={getVote(item.id)}
100
+ onVote={(voteType) => handleVote(item.id, voteType)}
101
+ isVoting={votingTasks.has(item.id)}
102
+ />
103
+ ))}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,163 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useRoadmapVoting — localStorage-backed optimistic voting for roadmap cards.
5
+ *
6
+ * One vote per task per user (storage key scoped per `storageKey` option,
7
+ * default `'roadmap_votes_v1'`). Toggling the same vote removes it;
8
+ * switching directions sends a remove + add pair so the server's running
9
+ * totals stay correct.
10
+ *
11
+ * Endpoint configuration — `voteApiEndpoint`:
12
+ * The hook posts to ONE endpoint (default `/api/roadmap/vote`) for
13
+ * BOTH the optimistic add AND the opposite-vote remove. Reverse-proxy
14
+ * embedders override this with their proxied path; lib otherwise
15
+ * matches the hub's pre-migration call shape.
16
+ */
17
+
18
+ import { useState, useEffect, useCallback } from 'react';
19
+
20
+ export type VoteType = 'up' | 'down' | null;
21
+
22
+ export interface VoteState {
23
+ [taskId: string]: VoteType;
24
+ }
25
+
26
+ export interface UseRoadmapVotingOptions {
27
+ /** Vote endpoint URL. Default `/api/roadmap/vote`. */
28
+ voteApiEndpoint?: string;
29
+ /** localStorage key. Default `'roadmap_votes_v1'`. Embedders mounting
30
+ * multiple roadmap surfaces in the same origin can scope per-surface
31
+ * (e.g. `'roadmap_votes_v1_main'` vs `'roadmap_votes_v1_admin'`) so
32
+ * votes don't cross-contaminate. */
33
+ storageKey?: string;
34
+ }
35
+
36
+ const DEFAULT_VOTE_ENDPOINT = '/api/roadmap/vote';
37
+ const DEFAULT_STORAGE_KEY = 'roadmap_votes_v1';
38
+
39
+ export function useRoadmapVoting(options: UseRoadmapVotingOptions = {}) {
40
+ const voteApiEndpoint = options.voteApiEndpoint ?? DEFAULT_VOTE_ENDPOINT;
41
+ const storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
42
+
43
+ const [votes, setVotes] = useState<VoteState>({});
44
+ const [isLoading, setIsLoading] = useState(true);
45
+
46
+ // Load votes from localStorage. Runs on mount AND whenever `storageKey`
47
+ // changes — when the key changes mid-lifecycle (e.g. an embedder
48
+ // remounts with a new namespace), we MUST reset state first so the
49
+ // save-effect below doesn't write the old key's data into the new
50
+ // key. We also re-enter the loading phase so the load completes
51
+ // before any save runs.
52
+ useEffect(() => {
53
+ setIsLoading(true);
54
+ setVotes({});
55
+ try {
56
+ const stored = localStorage.getItem(storageKey);
57
+ if (stored) {
58
+ setVotes(JSON.parse(stored));
59
+ }
60
+ } catch (error) {
61
+ console.error('[Voting] Error loading votes from localStorage:', error);
62
+ } finally {
63
+ setIsLoading(false);
64
+ }
65
+ }, [storageKey]);
66
+
67
+ // Save votes to localStorage whenever they change
68
+ useEffect(() => {
69
+ if (!isLoading) {
70
+ try {
71
+ localStorage.setItem(storageKey, JSON.stringify(votes));
72
+ } catch (error) {
73
+ console.error('[Voting] Error saving votes to localStorage:', error);
74
+ }
75
+ }
76
+ }, [votes, isLoading, storageKey]);
77
+
78
+ const getVote = useCallback(
79
+ (taskId: string): VoteType => {
80
+ return votes[taskId] || null;
81
+ },
82
+ [votes]
83
+ );
84
+
85
+ const toggleVote = useCallback(
86
+ async (
87
+ taskId: string,
88
+ voteType: 'up' | 'down'
89
+ ): Promise<{ success: boolean; newVote: VoteType; action: 'add' | 'remove' }> => {
90
+ const currentVote = votes[taskId];
91
+
92
+ let newVote: VoteType = null;
93
+ let action: 'add' | 'remove' = 'add';
94
+
95
+ if (currentVote === voteType) {
96
+ // User clicked same vote - remove it
97
+ newVote = null;
98
+ action = 'remove';
99
+ } else {
100
+ // User clicked different vote - set it. If they had an opposite
101
+ // vote, remove that first so the server totals stay consistent.
102
+ if (currentVote) {
103
+ await fetch(voteApiEndpoint, {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({
107
+ taskId,
108
+ voteType: currentVote,
109
+ action: 'remove',
110
+ }),
111
+ }).catch(err => console.error('[Voting] Error removing opposite vote:', err));
112
+ }
113
+
114
+ newVote = voteType;
115
+ action = 'add';
116
+ }
117
+
118
+ // Optimistic update
119
+ setVotes(prev => ({
120
+ ...prev,
121
+ [taskId]: newVote,
122
+ }));
123
+
124
+ try {
125
+ const response = await fetch(voteApiEndpoint, {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({ taskId, voteType, action }),
129
+ });
130
+
131
+ if (!response.ok) {
132
+ throw new Error('Vote API request failed');
133
+ }
134
+
135
+ return { success: true, newVote, action };
136
+ } catch (error) {
137
+ console.error('[Voting] API error:', error);
138
+
139
+ // Revert optimistic update on error
140
+ setVotes(prev => ({
141
+ ...prev,
142
+ [taskId]: currentVote,
143
+ }));
144
+
145
+ return { success: false, newVote: currentVote, action };
146
+ }
147
+ },
148
+ [votes, voteApiEndpoint]
149
+ );
150
+
151
+ const clearVotes = useCallback(() => {
152
+ setVotes({});
153
+ localStorage.removeItem(storageKey);
154
+ }, [storageKey]);
155
+
156
+ return {
157
+ votes,
158
+ isLoading,
159
+ getVote,
160
+ toggleVote,
161
+ clearVotes,
162
+ };
163
+ }