@flamingo-stack/openframe-frontend-core 0.0.274 → 0.0.275

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 (165) hide show
  1. package/dist/{chunk-XDGDCO3I.cjs → chunk-5LOLBUZG.cjs} +45 -44
  2. package/dist/chunk-5LOLBUZG.cjs.map +1 -0
  3. package/dist/{chunk-4YKGXZ7W.js → chunk-7AH75KFQ.js} +1 -1
  4. package/dist/chunk-7AH75KFQ.js.map +1 -0
  5. package/dist/{chunk-CYMMGPPV.js → chunk-CTBL56EC.js} +41 -5
  6. package/dist/chunk-CTBL56EC.js.map +1 -0
  7. package/dist/{chunk-2KHFSSFZ.cjs → chunk-DBJZF2OV.cjs} +15 -15
  8. package/dist/{chunk-2KHFSSFZ.cjs.map → chunk-DBJZF2OV.cjs.map} +1 -1
  9. package/dist/{chunk-4DYXV3E7.cjs → chunk-DEAWHNYW.cjs} +1177 -1223
  10. package/dist/chunk-DEAWHNYW.cjs.map +1 -0
  11. package/dist/{chunk-WU6PMH5L.cjs → chunk-E6RCVOQG.cjs} +41 -43
  12. package/dist/chunk-E6RCVOQG.cjs.map +1 -0
  13. package/dist/{chunk-FLIWL6V4.js → chunk-EINXNG5D.js} +4 -4
  14. package/dist/{chunk-OPSZNW3I.js → chunk-EKWPIEHH.js} +3031 -3077
  15. package/dist/chunk-EKWPIEHH.js.map +1 -0
  16. package/dist/{chunk-N5MCKGAB.js → chunk-FDHKJJPK.js} +34 -33
  17. package/dist/{chunk-N5MCKGAB.js.map → chunk-FDHKJJPK.js.map} +1 -1
  18. package/dist/{chunk-BSOHVQ7X.js → chunk-GHWOQUB4.js} +366 -391
  19. package/dist/chunk-GHWOQUB4.js.map +1 -0
  20. package/dist/{chunk-T7AJBKZP.cjs → chunk-GVSTRN36.cjs} +5 -2
  21. package/dist/chunk-GVSTRN36.cjs.map +1 -0
  22. package/dist/{chunk-L4LAAA5Q.js → chunk-IMNGGLKL.js} +5 -2
  23. package/dist/chunk-IMNGGLKL.js.map +1 -0
  24. package/dist/{chunk-VASNLWPZ.js → chunk-NY7OG2OJ.js} +5 -7
  25. package/dist/{chunk-VASNLWPZ.js.map → chunk-NY7OG2OJ.js.map} +1 -1
  26. package/dist/{chunk-5QJQAFTP.cjs → chunk-OKDCUJOZ.cjs} +48 -12
  27. package/dist/chunk-OKDCUJOZ.cjs.map +1 -0
  28. package/dist/{chunk-MEAR2WHS.cjs → chunk-PC7YOZ4I.cjs} +1 -1
  29. package/dist/chunk-PC7YOZ4I.cjs.map +1 -0
  30. package/dist/{chunk-QLWBBCI5.cjs → chunk-Q2ITV3NY.cjs} +5 -4
  31. package/dist/chunk-Q2ITV3NY.cjs.map +1 -0
  32. package/dist/chunk-RGALV4UX.js +433 -0
  33. package/dist/chunk-RGALV4UX.js.map +1 -0
  34. package/dist/{chunk-W5KBDFCR.cjs → chunk-SA4ZTD3G.cjs} +30 -30
  35. package/dist/{chunk-W5KBDFCR.cjs.map → chunk-SA4ZTD3G.cjs.map} +1 -1
  36. package/dist/{chunk-GL5X6VVC.js → chunk-T4XXYAYY.js} +4 -4
  37. package/dist/{chunk-ZMFT5XOB.js → chunk-UXJCZOLA.js} +4 -3
  38. package/dist/chunk-UXJCZOLA.js.map +1 -0
  39. package/dist/{chunk-7NJUPRN6.cjs → chunk-XCPBD2PG.cjs} +183 -208
  40. package/dist/chunk-XCPBD2PG.cjs.map +1 -0
  41. package/dist/chunk-ZSUNQJRO.cjs +433 -0
  42. package/dist/chunk-ZSUNQJRO.cjs.map +1 -0
  43. package/dist/components/chat/chat-message-row.d.ts +1 -1
  44. package/dist/components/chat/chat-message-row.d.ts.map +1 -1
  45. package/dist/components/chat/index.cjs +10 -11
  46. package/dist/components/chat/index.cjs.map +1 -1
  47. package/dist/components/chat/index.js +12 -13
  48. package/dist/components/contact/index.cjs +7 -9
  49. package/dist/components/contact/index.cjs.map +1 -1
  50. package/dist/components/contact/index.js +6 -8
  51. package/dist/components/faq/faq-document-page.d.ts +21 -0
  52. package/dist/components/faq/faq-document-page.d.ts.map +1 -0
  53. package/dist/components/faq/index.cjs +22 -5
  54. package/dist/components/faq/index.cjs.map +1 -1
  55. package/dist/components/faq/index.d.ts +1 -0
  56. package/dist/components/faq/index.d.ts.map +1 -1
  57. package/dist/components/faq/index.js +21 -4
  58. package/dist/components/features/index.cjs +6 -8
  59. package/dist/components/features/index.cjs.map +1 -1
  60. package/dist/components/features/index.js +5 -7
  61. package/dist/components/index.cjs +304 -309
  62. package/dist/components/index.cjs.map +1 -1
  63. package/dist/components/index.js +249 -254
  64. package/dist/components/index.js.map +1 -1
  65. package/dist/components/layout/article-detail-layout.d.ts +21 -9
  66. package/dist/components/layout/article-detail-layout.d.ts.map +1 -1
  67. package/dist/components/navigation/index.cjs +6 -8
  68. package/dist/components/navigation/index.cjs.map +1 -1
  69. package/dist/components/navigation/index.js +5 -7
  70. package/dist/components/onboarding-guides/index.cjs +33 -46
  71. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  72. package/dist/components/onboarding-guides/index.js +11 -24
  73. package/dist/components/onboarding-guides/index.js.map +1 -1
  74. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  75. package/dist/components/related-content/index.cjs +8 -10
  76. package/dist/components/related-content/index.cjs.map +1 -1
  77. package/dist/components/related-content/index.js +7 -9
  78. package/dist/components/shared/delivery/delivery-lists.d.ts.map +1 -1
  79. package/dist/components/shared/detail-page-skeleton.d.ts +6 -1
  80. package/dist/components/shared/detail-page-skeleton.d.ts.map +1 -1
  81. package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
  82. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  83. package/dist/components/shared/roadmap/roadmap-grid.d.ts.map +1 -1
  84. package/dist/components/shared/roadmap/use-roadmap-voting.d.ts.map +1 -1
  85. package/dist/components/tickets/help-center-list.d.ts +9 -1
  86. package/dist/components/tickets/help-center-list.d.ts.map +1 -1
  87. package/dist/components/tickets/hooks/use-ticket-actions.d.ts.map +1 -1
  88. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  89. package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
  90. package/dist/components/tickets/index.cjs +81 -76
  91. package/dist/components/tickets/index.cjs.map +1 -1
  92. package/dist/components/tickets/index.js +27 -22
  93. package/dist/components/tickets/index.js.map +1 -1
  94. package/dist/components/ui/index.cjs +12 -15
  95. package/dist/components/ui/index.cjs.map +1 -1
  96. package/dist/components/ui/index.js +24 -27
  97. package/dist/contexts/chat-runtime-context.d.ts +9 -0
  98. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  99. package/dist/contexts/index.cjs +2 -2
  100. package/dist/contexts/index.js +1 -1
  101. package/dist/hooks/index.cjs +3 -3
  102. package/dist/hooks/index.js +2 -2
  103. package/dist/hooks/use-contact-submission.d.ts.map +1 -1
  104. package/dist/hooks/use-self-fetch.d.ts.map +1 -1
  105. package/dist/index.cjs +11 -15
  106. package/dist/index.cjs.map +1 -1
  107. package/dist/index.js +32 -36
  108. package/dist/utils/embed-authed-fetch.d.ts +7 -0
  109. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  110. package/dist/utils/embed-content-fetch.d.ts +2 -0
  111. package/dist/utils/embed-content-fetch.d.ts.map +1 -0
  112. package/dist/utils/index.cjs.map +1 -1
  113. package/dist/utils/index.js.map +1 -1
  114. package/package.json +1 -1
  115. package/src/components/chat/chat-message-row.tsx +12 -2
  116. package/src/components/faq/faq-document-page.tsx +69 -0
  117. package/src/components/faq/index.ts +1 -0
  118. package/src/components/layout/article-detail-layout.tsx +24 -11
  119. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +21 -16
  120. package/src/components/shared/delivery/delivery-lists.tsx +3 -2
  121. package/src/components/shared/detail-page-skeleton.tsx +18 -10
  122. package/src/components/shared/legal-document/use-legal-docs.ts +2 -1
  123. package/src/components/shared/product-release/release-detail-page.tsx +27 -17
  124. package/src/components/shared/roadmap/roadmap-grid.tsx +2 -1
  125. package/src/components/shared/roadmap/use-roadmap-voting.ts +3 -2
  126. package/src/components/tickets/help-center-list.tsx +12 -4
  127. package/src/components/tickets/hooks/use-ticket-actions.ts +7 -2
  128. package/src/components/tickets/hooks/use-ticket-engagements.ts +6 -1
  129. package/src/components/tickets/hooks/use-tickets-list.ts +7 -1
  130. package/src/components/tickets/ticket-detail-drawer.tsx +1 -0
  131. package/src/contexts/chat-runtime-context.tsx +9 -0
  132. package/src/hooks/use-contact-submission.ts +2 -1
  133. package/src/hooks/use-self-fetch.ts +2 -1
  134. package/src/styles/index.css +1 -0
  135. package/src/styles/ods-page-shell.css +29 -0
  136. package/src/utils/embed-authed-fetch.ts +10 -0
  137. package/src/utils/embed-content-fetch.ts +30 -0
  138. package/dist/chunk-4DYXV3E7.cjs.map +0 -1
  139. package/dist/chunk-4YKGXZ7W.js.map +0 -1
  140. package/dist/chunk-5QJQAFTP.cjs.map +0 -1
  141. package/dist/chunk-722TTRIJ.cjs +0 -29
  142. package/dist/chunk-722TTRIJ.cjs.map +0 -1
  143. package/dist/chunk-7NJUPRN6.cjs.map +0 -1
  144. package/dist/chunk-BSOHVQ7X.js.map +0 -1
  145. package/dist/chunk-CYMMGPPV.js.map +0 -1
  146. package/dist/chunk-DAAFVOTL.cjs +0 -172
  147. package/dist/chunk-DAAFVOTL.cjs.map +0 -1
  148. package/dist/chunk-L4LAAA5Q.js.map +0 -1
  149. package/dist/chunk-MEAR2WHS.cjs.map +0 -1
  150. package/dist/chunk-OO4ZGDRO.js +0 -29
  151. package/dist/chunk-OO4ZGDRO.js.map +0 -1
  152. package/dist/chunk-OPSZNW3I.js.map +0 -1
  153. package/dist/chunk-QLWBBCI5.cjs.map +0 -1
  154. package/dist/chunk-T2URJAX2.js +0 -169
  155. package/dist/chunk-T2URJAX2.js.map +0 -1
  156. package/dist/chunk-T7AJBKZP.cjs.map +0 -1
  157. package/dist/chunk-WU6PMH5L.cjs.map +0 -1
  158. package/dist/chunk-XDGDCO3I.cjs.map +0 -1
  159. package/dist/chunk-Y2B6WDU6.cjs +0 -169
  160. package/dist/chunk-Y2B6WDU6.cjs.map +0 -1
  161. package/dist/chunk-ZMFT5XOB.js.map +0 -1
  162. package/dist/chunk-ZXZJSTNV.js +0 -172
  163. package/dist/chunk-ZXZJSTNV.js.map +0 -1
  164. /package/dist/{chunk-FLIWL6V4.js.map → chunk-EINXNG5D.js.map} +0 -0
  165. /package/dist/{chunk-GL5X6VVC.js.map → chunk-T4XXYAYY.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.274",
3
+ "version": "0.0.275",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  import Image from '../../embed-shims/next-image'
23
23
  import { getFirstLastInitials } from '../../utils/format'
24
24
  import { useProxiedImageUrl } from './hooks/use-proxied-image-url'
25
- import type { ReactNode } from 'react'
25
+ import { useState, type ReactNode } from 'react'
26
26
 
27
27
  export interface ChatMessageRowProps {
28
28
  /** Display name (bold, top-left). */
@@ -47,8 +47,17 @@ export function ChatMessageRow({
47
47
  body,
48
48
  footer,
49
49
  }: ChatMessageRowProps) {
50
+ // Avatars load directly from their (https) host — same as the rest of the app,
51
+ // so the browser disk-caches them (Google/etc. send `cache-control: max-age`).
52
+ // `useProxiedImageUrl` only rewrites http/relative URLs; https pass through.
50
53
  const proxiedAvatar = useProxiedImageUrl(avatarUrl ?? '')
51
- const src = proxiedAvatar || avatarUrl || undefined
54
+ const resolvedAvatar = proxiedAvatar || avatarUrl || undefined
55
+ // Fall back to the initials box if the avatar load FAILS (transient CDN 429,
56
+ // ad-blocker, dead URL) instead of showing a broken image. Keyed on the URL
57
+ // (not a bool) so a later render with a DIFFERENT avatar re-attempts the load
58
+ // rather than inheriting a stale failure.
59
+ const [failedSrc, setFailedSrc] = useState<string | null>(null)
60
+ const src = resolvedAvatar && resolvedAvatar !== failedSrc ? resolvedAvatar : undefined
52
61
  const hasBody = body.trim().length > 0
53
62
 
54
63
  return (
@@ -63,6 +72,7 @@ export function ChatMessageRow({
63
72
  className="w-8 h-8 md:w-10 md:h-10 rounded-lg object-cover flex-shrink-0"
64
73
  width={40}
65
74
  height={40}
75
+ onError={() => setFailedSrc(resolvedAvatar ?? null)}
66
76
  />
67
77
  ) : (
68
78
  <div className="w-8 h-8 md:w-10 md:h-10 rounded-lg flex-shrink-0 flex items-center justify-center bg-ods-bg border border-ods-border text-[12px] font-medium text-ods-text-primary font-body">
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * FaqDocumentPage — the full `/faqs` page with chrome, so embedders drop in
5
+ * ONE component instead of hand-assembling `PageShell` + `PageLayout` around the
6
+ * bare `<FaqSection>`. Mirrors `LegalDocumentPage` / `DevSectionPage`: the
7
+ * page-level layout lives in the lib, the host passes only config + a back button.
8
+ *
9
+ * `<FaqSection heading={null}>` lets `PageLayout`'s `TitleBlock` own the heading +
10
+ * back button; it self-fetches `${apiBaseUrl}/api/faqs` via the authed
11
+ * `useSelfFetch`, and renders nothing on a fetch error or zero FAQs.
12
+ */
13
+
14
+ import { PageShell, PageLayout } from '../ui';
15
+ import { useRouter } from '../../embed-shims/next-navigation';
16
+ import { FaqSection } from './faq-section';
17
+
18
+ export interface FaqDocumentPageProps {
19
+ /** Page title (PageLayout TitleBlock). Default "FAQs". */
20
+ title?: string;
21
+ /** Subtitle under the title. */
22
+ subtitle?: string;
23
+ /** Back-button config — same pattern as `DevSectionPage` / `LegalDocumentPage`.
24
+ * Pass `false` to hide. Default `{ label: 'Back to home', href: '/' }`. */
25
+ backButton?: { label?: string; href?: string } | false;
26
+ /** Base URL `FaqSection` appends `/api/faqs` to (reverse-proxy embedders). */
27
+ apiBaseUrl?: string;
28
+ /** Optional entity scoping forwarded to `FaqSection`. */
29
+ entityType?: string;
30
+ entityId?: number | string;
31
+ /** Minimum FAQ count before the section renders (forwarded to `FaqSection`). */
32
+ minResults?: number;
33
+ }
34
+
35
+ export function FaqDocumentPage({
36
+ title = 'FAQs',
37
+ subtitle,
38
+ backButton,
39
+ apiBaseUrl,
40
+ entityType,
41
+ entityId,
42
+ minResults,
43
+ }: FaqDocumentPageProps) {
44
+ const router = useRouter();
45
+
46
+ // Back-button config — mirrors LegalDocumentPage/DevSectionPage. Hide entirely
47
+ // when the caller passes `false` (embed-mode where the host owns nav chrome).
48
+ const backCfg =
49
+ backButton === false
50
+ ? undefined
51
+ : {
52
+ label: backButton?.label ?? 'Back to home',
53
+ onClick: () => router.push(backButton?.href ?? '/'),
54
+ };
55
+
56
+ return (
57
+ <PageShell>
58
+ <PageLayout title={title} subtitle={subtitle} backButton={backCfg}>
59
+ <FaqSection
60
+ heading={null}
61
+ apiBaseUrl={apiBaseUrl}
62
+ entityType={entityType}
63
+ entityId={entityId}
64
+ minResults={minResults}
65
+ />
66
+ </PageLayout>
67
+ </PageShell>
68
+ );
69
+ }
@@ -10,3 +10,4 @@
10
10
  // (Server-safe subpath is wired in tsup.config.ts under the server/universal
11
11
  // block and exposed via "./components/faq/json-ld" in package.json#exports.)
12
12
  export { FaqSection, type FaqSectionProps } from './faq-section'
13
+ export { FaqDocumentPage, type FaqDocumentPageProps } from './faq-document-page'
@@ -1,30 +1,41 @@
1
1
  import React from 'react';
2
+ import { cn } from '../../utils/cn';
2
3
 
3
4
  /**
4
5
  * Unified page layout components.
5
6
  *
6
- * PageShell: Full-width layout for list pages (max-w-[1920px])
7
- * ArticleDetailLayout: Constrained layout for detail pages (max-w-[1280px])
7
+ * PageShell: full-width <main> for list pages, dashboards, and other wide
8
+ * layouts (max-w-[1920px]) — bg + min-height + centered, max-width content box.
9
+ * ArticleDetailLayout: the same shell constrained to a readable article width
10
+ * (max-w-[1280px]) — blog posts, release notes, case studies, etc.
11
+ * Child components render content only; no layout wrappers needed.
8
12
  *
9
- * Both provide: <main> + bg-ods-bg + responsive padding + max-width + centering.
10
- * Child components should render content only no layout wrappers needed.
13
+ * Outer padding is driven by CSS custom properties (see
14
+ * `styles/ods-page-shell.css`), so an embedder matches its host grid by setting
15
+ * the `--page-shell-*` vars on ANY ancestor — no prop-threading through the lib
16
+ * wrappers (DevSectionPage / LegalDocumentPage / ReleaseDetailPage / …), no
17
+ * imperative global, no context. The cascade scopes each override to its own
18
+ * subtree. Per-instance escape hatch: `contentClassName` (Tailwind padding
19
+ * utilities there win over the class defaults).
11
20
  */
12
21
 
13
22
  interface LayoutProps {
14
23
  children: React.ReactNode;
15
24
  /** JSON-LD schema script elements (breadcrumbs, article schema, etc.) */
16
25
  schemas?: React.ReactNode;
26
+ /** Per-instance class override on the content box (wins over the var defaults). */
27
+ contentClassName?: string;
17
28
  }
18
29
 
19
30
  /**
20
31
  * Full-width page shell for list pages, dashboards, and other wide layouts.
21
32
  * Max width: 1920px.
22
33
  */
23
- export function PageShell({ children, schemas }: LayoutProps) {
34
+ export function PageShell({ children, schemas, contentClassName }: LayoutProps) {
24
35
  return (
25
36
  <main className="bg-ods-bg min-h-screen">
26
37
  {schemas}
27
- <div className="max-w-[1920px] mx-auto px-6 md:px-20 py-6 md:py-10">
38
+ <div className={cn('page-shell-content max-w-[1920px] mx-auto', contentClassName)}>
28
39
  {children}
29
40
  </div>
30
41
  </main>
@@ -32,15 +43,17 @@ export function PageShell({ children, schemas }: LayoutProps) {
32
43
  }
33
44
 
34
45
  /**
35
- * Constrained layout for article/detail pages.
36
- * Max width: 1280px for readable content width.
37
- * Used by: release pages, blog posts, case studies, investor updates, interviews.
46
+ * Constrained layout for article/detail pages (max-w-[1280px]) — readable
47
+ * content width for blog posts, release notes, case studies, investor updates.
48
+ * Fixed hub padding (`px-6 md:px-20 py-6 md:py-10`); pass `contentClassName` to
49
+ * override per instance. (PageShell's `--page-shell-*` var system does NOT apply
50
+ * here — this layout keeps its own fixed spacing.)
38
51
  */
39
- export function ArticleDetailLayout({ children, schemas }: LayoutProps) {
52
+ export function ArticleDetailLayout({ children, schemas, contentClassName }: LayoutProps) {
40
53
  return (
41
54
  <main className="bg-ods-bg min-h-screen">
42
55
  {schemas}
43
- <div className="max-w-[1280px] mx-auto px-6 md:px-20 py-6 md:py-10">
56
+ <div className={cn('max-w-[1280px] mx-auto px-6 md:px-20 py-6 md:py-10', contentClassName)}>
44
57
  {children}
45
58
  </div>
46
59
  </main>
@@ -16,9 +16,9 @@
16
16
  */
17
17
 
18
18
  import { type ComponentType, type ReactNode } from 'react'
19
- import { ArrowLeft } from 'lucide-react'
19
+ import { PageLayout } from '../layout/page-layout'
20
20
 
21
- import { Link } from '../../embed-shims'
21
+ import { useRouter } from '../../embed-shims'
22
22
  // PageShell (max-w-[1920px]) — the guide detail content must share the SAME
23
23
  // container sizing as the related-content/FAQ rail the host page renders
24
24
  // below it (which uses the wide shell). ArticleDetailLayout (1280px) made
@@ -91,6 +91,7 @@ export function OnboardingGuideDetailView({
91
91
  }: OnboardingGuideDetailViewProps) {
92
92
  const resolvedBackHref = backHref ?? basePath
93
93
  const runtime = useChatRuntime()
94
+ const router = useRouter()
94
95
 
95
96
  // Controlled (hub SSR `initialData`) OR self-fetch by slug (config-only embed).
96
97
  const url = initialData ? null : slug && guideEndpoint ? guideEndpoint(slug) : null
@@ -104,13 +105,26 @@ export function OnboardingGuideDetailView({
104
105
  })
105
106
 
106
107
  if (error || (!guide && !isLoading)) {
107
- return <LoadError message="Guide not found." onRetry={reload} />
108
+ return (
109
+ <PageShell>
110
+ <LoadError message="Guide not found." onRetry={reload} />
111
+ </PageShell>
112
+ )
108
113
  }
109
114
  if (!guide) {
110
115
  // Skeleton (not a bare "Loading…") for parity with every other shared view —
111
116
  // catalog, roadmap, releases all render a skeleton on first load, so the detail
112
- // page shouldn't flash text then content.
113
- return <DetailPageSkeleton showImageGallery={false} />
117
+ // page shouldn't flash text then content. `bare` + `PageShell` so the loading
118
+ // state matches the loaded page's full width / padding / min-height.
119
+ return (
120
+ <PageShell>
121
+ {/* Match the loaded page's top offset (TitleBlock's
122
+ `pt-[var(--spacing-system-l)]`) so content doesn't jump on load. */}
123
+ <div className="pt-[var(--spacing-system-l)]">
124
+ <DetailPageSkeleton bare showImageGallery={false} />
125
+ </div>
126
+ </PageShell>
127
+ )
114
128
  }
115
129
 
116
130
  const captionsUrl = getCaptionsUrl('onboarding_guide', guide.id, guide.srt_content)
@@ -135,16 +149,7 @@ export function OnboardingGuideDetailView({
135
149
 
136
150
  return (
137
151
  <PageShell>
138
- <div className="space-y-6 md:space-y-8">
139
- {/* Back link */}
140
- <Link
141
- href={resolvedBackHref}
142
- className="inline-flex items-center gap-2 text-ods-text-secondary hover:text-ods-accent transition-colors"
143
- >
144
- <ArrowLeft className="h-4 w-4" />
145
- <span className="text-h5">{backLabel}</span>
146
- </Link>
147
-
152
+ <PageLayout backButton={{ label: backLabel, onClick: () => router.push(resolvedBackHref) }}>
148
153
  <h1 className="text-h1 tracking-[-1.12px] text-ods-text-primary">{guide.title}</h1>
149
154
 
150
155
  {/* Tags — flat onboarding_guide_tags[] from entity_tags. */}
@@ -225,7 +230,7 @@ export function OnboardingGuideDetailView({
225
230
  </ul>
226
231
  </div>
227
232
  )}
228
- </div>
233
+ </PageLayout>
229
234
  </PageShell>
230
235
  )
231
236
  }
@@ -35,6 +35,7 @@ import { DeliveryTable } from './delivery-table';
35
35
  import { EmptyState } from '../../empty-state';
36
36
  import { LoadError } from '../../ui/error-state';
37
37
  import { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys';
38
+ import { contentFetch } from '../../../utils/embed-content-fetch';
38
39
 
39
40
  const DEFAULT_COMPLETED_ENDPOINT = '/api/delivery/completed';
40
41
  const DEFAULT_IN_PROGRESS_ENDPOINT = '/api/delivery/in-progress';
@@ -97,8 +98,8 @@ export function DeliveryLists({
97
98
 
98
99
  // Fetch completed and in-progress tasks separately with filters
99
100
  const [completedResponse, inProgressResponse] = await Promise.all([
100
- fetch(`${completedApiEndpoint}${queryParam}`),
101
- fetch(`${inProgressApiEndpoint}${queryParam}`),
101
+ contentFetch(`${completedApiEndpoint}${queryParam}`),
102
+ contentFetch(`${inProgressApiEndpoint}${queryParam}`),
102
103
  ]);
103
104
 
104
105
  if (!completedResponse.ok || !inProgressResponse.ok) {
@@ -1,23 +1,23 @@
1
1
  "use client";
2
2
 
3
- import { PageContainer } from '../layout/page-container';
3
+ import { PageLayout } from '../layout/page-layout';
4
4
 
5
5
  export interface DetailPageSkeletonProps {
6
6
  metadataColumns?: number; // Number of metadata grid columns (default 4)
7
7
  showImageGallery?: boolean; // Show horizontal image gallery (default true)
8
+ /** Render only the skeleton blocks, WITHOUT the self-contained page wrapper
9
+ * — the caller supplies its own (e.g. `<PageShell>`) so the loading state
10
+ * matches the loaded page's width, padding, and min-height. Default false
11
+ * (self-contained `<PageLayout>` at the hub article width). */
12
+ bare?: boolean;
8
13
  }
9
14
 
10
15
  export function DetailPageSkeleton({
11
16
  metadataColumns = 4,
12
- showImageGallery = true
17
+ showImageGallery = true,
18
+ bare = false
13
19
  }: DetailPageSkeletonProps = {}) {
14
- return (
15
- <PageContainer
16
- as="article"
17
- backgroundClassName="bg-ods-bg"
18
- contentPadding="py-6 md:py-10 px-6 md:px-20"
19
- maxWidth="max-w-[1280px]"
20
- >
20
+ const content = (
21
21
  <div className="space-y-6 md:space-y-10 animate-pulse">
22
22
  {/* Title Block */}
23
23
  <div className="flex flex-col gap-6 w-full">
@@ -67,6 +67,14 @@ export function DetailPageSkeleton({
67
67
  </div>
68
68
  ))}
69
69
  </div>
70
- </PageContainer>
70
+ );
71
+ if (bare) return content;
72
+ return (
73
+ <PageLayout
74
+ showHeader={false}
75
+ className="bg-ods-bg max-w-[1280px] mx-auto py-6 md:py-10 px-6 md:px-20"
76
+ >
77
+ {content}
78
+ </PageLayout>
71
79
  );
72
80
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { useState, useEffect, useCallback } from 'react';
17
+ import { contentFetch } from '../../../utils/embed-content-fetch';
17
18
 
18
19
  export interface LegalDocument {
19
20
  title: string;
@@ -68,7 +69,7 @@ export function useLegalDocs(
68
69
  setIsLoading(true);
69
70
  setError(null);
70
71
 
71
- const response = await fetch(effectiveEndpoint);
72
+ const response = await contentFetch(effectiveEndpoint);
72
73
 
73
74
  if (!response.ok) {
74
75
  throw new Error(
@@ -8,7 +8,7 @@ import { Card, CardContent } from '../../ui/card';
8
8
  // renders below this view (was ArticleDetailLayout, 1280px — narrower than
9
9
  // the rail, see hub detail-container alignment decision 2026-06-10).
10
10
  import { PageShell } from '../../layout/article-detail-layout';
11
- import { BackButton } from '../../layout/back-button';
11
+ import { PageLayout } from '../../layout/page-layout';
12
12
  import { ReleaseChangelogSection } from '../../ui/release-changelog-section';
13
13
  import { EntityTagBadges } from '../../features/entity-tag-badges';
14
14
  import { EntityMetadataAuthorCell } from '../../chat/entity-cards/entity-author-card';
@@ -17,6 +17,7 @@ import { MediaGalleryStrip } from '../media-gallery-strip';
17
17
  import { GitHubIcon } from '../../icons/github-icon';
18
18
  import { AlertTriangle, ExternalLink, BookMarked, Sparkles, TrendingUp, Wrench } from 'lucide-react';
19
19
  import { formatReleaseDate } from '../../../utils/date-formatters';
20
+ import { contentFetch } from '../../../utils/embed-content-fetch';
20
21
  import { Video } from '../../features/video';
21
22
  import { DetailPageSkeleton } from '../detail-page-skeleton';
22
23
  import type { ChangelogEntry } from '../../../types/product-release';
@@ -158,7 +159,7 @@ export function ReleaseDetailPage({
158
159
  if (roadmapTasksData && roadmapTasksData.length > 0 && RoadmapSection) {
159
160
  setRoadmapLoading(true);
160
161
  const roadmapIds = roadmapTasksData.map(t => t.clickup_task_id).join(',');
161
- const roadmapResponse = await fetch(`${roadmapApiEndpoint}?task_ids=${roadmapIds}`);
162
+ const roadmapResponse = await contentFetch(`${roadmapApiEndpoint}?task_ids=${roadmapIds}`);
162
163
  const roadmapData = await roadmapResponse.json();
163
164
  setRoadmapTasks(roadmapData.items || []);
164
165
  setRoadmapLoading(false);
@@ -169,7 +170,7 @@ export function ReleaseDetailPage({
169
170
  if (deliveryTasksData && deliveryTasksData.length > 0 && DeliverySection) {
170
171
  setDeliveryLoading(true);
171
172
  const deliveryIds = deliveryTasksData.map(t => t.clickup_task_id).join(',');
172
- const deliveryResponse = await fetch(`${deliveryApiEndpoint}?task_ids=${deliveryIds}`);
173
+ const deliveryResponse = await contentFetch(`${deliveryApiEndpoint}?task_ids=${deliveryIds}`);
173
174
  const deliveryResponseData = await deliveryResponse.json();
174
175
  setDeliveryData(deliveryResponseData);
175
176
  setDeliveryLoading(false);
@@ -186,15 +187,27 @@ export function ReleaseDetailPage({
186
187
 
187
188
  // Don't show loading skeleton if we have initialData
188
189
  if (!initialData && isLoading) {
189
- return <DetailPageSkeleton metadataColumns={4} showImageGallery={true} />;
190
+ // `bare` + `PageShell` so the loading state matches the loaded page's full
191
+ // width / padding / min-height (the wrapper supplies the page chrome).
192
+ return (
193
+ <PageShell>
194
+ {/* Match the loaded page's top offset (TitleBlock's
195
+ `pt-[var(--spacing-system-l)]`) so content doesn't jump on load. */}
196
+ <div className="pt-[var(--spacing-system-l)]">
197
+ <DetailPageSkeleton bare metadataColumns={4} showImageGallery={true} />
198
+ </div>
199
+ </PageShell>
200
+ );
190
201
  }
191
202
 
192
203
  if (error || !release) {
193
204
  return (
194
- <div className="text-center py-16">
195
- <h1 className="text-4xl font-bold text-ods-text-primary mb-4">Release Not Found</h1>
196
- <p className="text-xl text-ods-text-secondary">The release you&apos;re looking for doesn&apos;t exist.</p>
197
- </div>
205
+ <PageShell>
206
+ <div className="text-center py-16">
207
+ <h1 className="text-4xl font-bold text-ods-text-primary mb-4">Release Not Found</h1>
208
+ <p className="text-xl text-ods-text-secondary">The release you&apos;re looking for doesn&apos;t exist.</p>
209
+ </div>
210
+ </PageShell>
198
211
  );
199
212
  }
200
213
 
@@ -228,15 +241,11 @@ export function ReleaseDetailPage({
228
241
 
229
242
  return (
230
243
  <PageShell>
231
- {/* Back button — desktop-only, matches DevSectionPage / LegalDocumentPage
232
- (TitleBlock renders the same `hidden md:inline-flex` BackButton). */}
233
- {showBackButton && (
234
- <BackButton
235
- label={backLabel}
236
- onClick={() => router.push(backHref)}
237
- className="hidden md:inline-flex mb-4"
238
- />
239
- )}
244
+ <PageLayout
245
+ backButton={
246
+ showBackButton ? { label: backLabel, onClick: () => router.push(backHref) } : undefined
247
+ }
248
+ >
240
249
  <div className="space-y-6 md:space-y-8">
241
250
  {/* Title Block */}
242
251
  <div className="flex flex-col md:flex-row md:items-end gap-4 w-full">
@@ -557,6 +566,7 @@ export function ReleaseDetailPage({
557
566
  </div>
558
567
  )}
559
568
  </div>
569
+ </PageLayout>
560
570
  </PageShell>
561
571
  );
562
572
  }
@@ -33,6 +33,7 @@ import {
33
33
  AccordionContent,
34
34
  } from '../../ui';
35
35
  import { cn } from '../../../utils/cn';
36
+ import { contentFetch } from '../../../utils/embed-content-fetch';
36
37
  import type { RoadmapItem } from '../../chat/types/entities/roadmap-item';
37
38
 
38
39
  const DEFAULT_BUILD_REFRESH_URL = (taskId: string) => `/api/roadmap/${taskId}`;
@@ -165,7 +166,7 @@ export function RoadmapGrid({
165
166
  try {
166
167
  const result = await toggleVote(taskId, voteType);
167
168
  if (result.success) {
168
- const response = await fetch(buildRefreshUrl(taskId));
169
+ const response = await contentFetch(buildRefreshUrl(taskId));
169
170
  if (response.ok) {
170
171
  const data = await response.json();
171
172
  if (data.item && onItemUpdate) onItemUpdate(data.item);
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { useState, useEffect, useCallback } from 'react';
19
+ import { contentFetch } from '../../../utils/embed-content-fetch';
19
20
 
20
21
  export type VoteType = 'up' | 'down' | null;
21
22
 
@@ -100,7 +101,7 @@ export function useRoadmapVoting(options: UseRoadmapVotingOptions = {}) {
100
101
  // User clicked different vote - set it. If they had an opposite
101
102
  // vote, remove that first so the server totals stay consistent.
102
103
  if (currentVote) {
103
- await fetch(voteApiEndpoint, {
104
+ await contentFetch(voteApiEndpoint, {
104
105
  method: 'POST',
105
106
  headers: { 'Content-Type': 'application/json' },
106
107
  body: JSON.stringify({
@@ -122,7 +123,7 @@ export function useRoadmapVoting(options: UseRoadmapVotingOptions = {}) {
122
123
  }));
123
124
 
124
125
  try {
125
- const response = await fetch(voteApiEndpoint, {
126
+ const response = await contentFetch(voteApiEndpoint, {
126
127
  method: 'POST',
127
128
  headers: { 'Content-Type': 'application/json' },
128
129
  body: JSON.stringify({ taskId, voteType, action }),
@@ -47,9 +47,14 @@ export interface HelpCenterListProps {
47
47
  /** Toast override (test-friendly). Defaults to the lib's shared
48
48
  * toast singleton. */
49
49
  toast?: typeof defaultToast
50
+ /** Back-button forwarded to the internal `DevSectionPage` chrome (same shape
51
+ * as `DevSectionPage` / `LegalDocumentPage`: `{ label?, href? }`, or `false`
52
+ * to hide). Omit ⇒ `DevSectionPage`'s default (`Back to home` → `/`), which
53
+ * embedders whose home isn't `/` MUST override. */
54
+ backButton?: { label?: string; href?: string } | false
50
55
  }
51
56
 
52
- export function HelpCenterList({ toast = defaultToast }: HelpCenterListProps = {}) {
57
+ export function HelpCenterList({ toast = defaultToast, backButton }: HelpCenterListProps = {}) {
53
58
  const identity = useChatIdentity()
54
59
  const searchParams = useSearchParams()
55
60
  const router = useRouter()
@@ -77,14 +82,14 @@ export function HelpCenterList({ toast = defaultToast }: HelpCenterListProps = {
77
82
  // mounts in the `preControls` slot.
78
83
  if (identity.isLoading) {
79
84
  return (
80
- <DevSectionPage sectionKey="tickets" preControls={<HelpCenterCreateFormSkeleton />}>
85
+ <DevSectionPage sectionKey="tickets" backButton={backButton} preControls={<HelpCenterCreateFormSkeleton />}>
81
86
  <DevCardRowSkeletonList />
82
87
  </DevSectionPage>
83
88
  )
84
89
  }
85
90
  if (identity.authTier === 'anon' || !identity.user?.email) {
86
91
  return (
87
- <DevSectionPage sectionKey="tickets">
92
+ <DevSectionPage sectionKey="tickets" backButton={backButton}>
88
93
  <EmptyState
89
94
  type="generic"
90
95
  title="Sign in to manage tickets"
@@ -119,6 +124,7 @@ export function HelpCenterList({ toast = defaultToast }: HelpCenterListProps = {
119
124
  toast={toast}
120
125
  sessionName={sessionName}
121
126
  sessionEmail={sessionEmail}
127
+ backButton={backButton}
122
128
  />
123
129
  )
124
130
  }
@@ -135,6 +141,7 @@ interface AuthedProps {
135
141
  toast: typeof defaultToast
136
142
  sessionName: string
137
143
  sessionEmail: string
144
+ backButton?: { label?: string; href?: string } | false
138
145
  }
139
146
 
140
147
  function HelpCenterListAuthed({
@@ -148,6 +155,7 @@ function HelpCenterListAuthed({
148
155
  toast,
149
156
  sessionName,
150
157
  sessionEmail,
158
+ backButton,
151
159
  }: AuthedProps) {
152
160
  const queryClient = useQueryClient()
153
161
  const [optimisticTickets, setOptimisticTickets] = useState<OptimisticTicket[]>([])
@@ -365,7 +373,7 @@ function HelpCenterListAuthed({
365
373
  )
366
374
 
367
375
  return (
368
- <DevSectionPage sectionKey="tickets" preControls={form}>
376
+ <DevSectionPage sectionKey="tickets" backButton={backButton} preControls={form}>
369
377
  {body}
370
378
  </DevSectionPage>
371
379
  )
@@ -31,6 +31,7 @@
31
31
 
32
32
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
33
  import { useQueryClient } from '@tanstack/react-query'
34
+ import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
34
35
  import { embedAuthedFetch } from '../../../utils/embed-authed-fetch'
35
36
  import type { ChatAttachment } from '../../chat/utils/chat-attachment-markdown'
36
37
  import {
@@ -149,6 +150,10 @@ export interface UseTicketActionsReturn {
149
150
 
150
151
  export function useTicketActions(options: UseTicketActionsOptions): UseTicketActionsReturn {
151
152
  const queryClient = useQueryClient()
153
+ // Endpoint from the runtime config (like every other endpoint); falls back to
154
+ // the bare hub path when unconfigured.
155
+ const ticketActionEndpoint =
156
+ useRequiredChatRuntime().endpoints.ticketActionUrl ?? TICKET_ACTION_ENDPOINT
152
157
  const { prependOptimistic, removeOptimistic, removeTicketFromCache, toast, onSupportSystemDown } = options
153
158
 
154
159
  // Form-level single-flight uses BOTH a ref (for synchronous guarding
@@ -234,7 +239,7 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
234
239
 
235
240
  const executeTicketAction = useCallback(
236
241
  async (toolName: ToolName, args: Record<string, unknown>): Promise<TicketActionResponse> => {
237
- const res = await embedAuthedFetch(TICKET_ACTION_ENDPOINT, {
242
+ const res = await embedAuthedFetch(ticketActionEndpoint, {
238
243
  method: 'POST',
239
244
  body: JSON.stringify({ tool_name: toolName, args }),
240
245
  })
@@ -248,7 +253,7 @@ export function useTicketActions(options: UseTicketActionsOptions): UseTicketAct
248
253
  }
249
254
  return body
250
255
  },
251
- [],
256
+ [ticketActionEndpoint],
252
257
  )
253
258
 
254
259
  // Mirror-sync watcher — backoff refetches when the post-create mirror
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { useQuery } from '@tanstack/react-query'
18
+ import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
18
19
  import { embedAuthedFetch } from '../../../utils/embed-authed-fetch'
19
20
  import { useChatIdentity } from '../../chat/hooks/use-chat-identity'
20
21
 
@@ -87,6 +88,10 @@ export function useTicketEngagements(
87
88
  ): UseTicketEngagementsReturn {
88
89
  const identity = useChatIdentity()
89
90
  const identityKey = identity.user?.email ?? 'anon'
91
+ // Endpoint from the runtime config (like every other endpoint); falls back to
92
+ // the bare hub path when unconfigured.
93
+ const listEngagementsEndpoint =
94
+ useRequiredChatRuntime().endpoints.listEngagementsUrl ?? LIST_ENGAGEMENTS_ENDPOINT
90
95
 
91
96
  // "Will this ticket fetch its timeline once identity is ready?" — i.e. it's a
92
97
  // real, non-optimistic ticket the caller enabled. INDEPENDENT of whether
@@ -116,7 +121,7 @@ export function useTicketEngagements(
116
121
  // so polling pauses on a hidden tab.
117
122
  refetchInterval,
118
123
  queryFn: async (): Promise<TicketEngagement[]> => {
119
- const response = await embedAuthedFetch(LIST_ENGAGEMENTS_ENDPOINT, {
124
+ const response = await embedAuthedFetch(listEngagementsEndpoint, {
120
125
  method: 'POST',
121
126
  body: JSON.stringify({ ticket_id: externalTicketId }),
122
127
  })
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { useQuery } from '@tanstack/react-query'
23
+ import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
23
24
  import { embedAuthedFetch } from '../../../utils/embed-authed-fetch'
24
25
  import type { TicketData } from '../types'
25
26
 
@@ -91,6 +92,11 @@ export interface UseTicketsListReturn {
91
92
  }
92
93
 
93
94
  export function useTicketsList(filters: UseTicketsListFilters): UseTicketsListReturn {
95
+ // Endpoint from the runtime config (like every other endpoint) so embedders
96
+ // behind a reverse proxy route it through `/content/*`; falls back to the bare
97
+ // hub path when unconfigured.
98
+ const findTicketEndpoint =
99
+ useRequiredChatRuntime().endpoints.findTicketUrl ?? FIND_TICKET_ENDPOINT
94
100
  const customerEmail = filters.customerEmail
95
101
  const search = (filters.search ?? '').trim()
96
102
  const status = (filters.status ?? '').trim().toLowerCase()
@@ -135,7 +141,7 @@ export function useTicketsList(filters: UseTicketsListFilters): UseTicketsListRe
135
141
  pageSize,
136
142
  }
137
143
  if (statusFilter) body.status = statusFilter
138
- const response = await embedAuthedFetch(FIND_TICKET_ENDPOINT, {
144
+ const response = await embedAuthedFetch(findTicketEndpoint, {
139
145
  method: 'POST',
140
146
  body: JSON.stringify(body),
141
147
  })