@flamingo-stack/openframe-frontend-core 0.0.286 → 0.0.287

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 (81) hide show
  1. package/dist/{chunk-HLQW7MWJ.cjs → chunk-3SDBXXDP.cjs} +21 -21
  2. package/dist/{chunk-HLQW7MWJ.cjs.map → chunk-3SDBXXDP.cjs.map} +1 -1
  3. package/dist/{chunk-4WACBTZU.cjs → chunk-6AW25OS6.cjs} +25 -25
  4. package/dist/{chunk-4WACBTZU.cjs.map → chunk-6AW25OS6.cjs.map} +1 -1
  5. package/dist/{chunk-QCKN37OP.cjs → chunk-6CSW5TMS.cjs} +35 -35
  6. package/dist/{chunk-QCKN37OP.cjs.map → chunk-6CSW5TMS.cjs.map} +1 -1
  7. package/dist/{chunk-YVB3VDIQ.js → chunk-7EYWERFT.js} +2 -2
  8. package/dist/{chunk-6N26CURS.cjs → chunk-D6RK5YXX.cjs} +25 -3
  9. package/dist/chunk-D6RK5YXX.cjs.map +1 -0
  10. package/dist/{chunk-FSBDVT6R.js → chunk-EFYXPR43.js} +2 -2
  11. package/dist/{chunk-XQL4WDML.js → chunk-GJDXIVEQ.js} +3 -3
  12. package/dist/{chunk-D5YY5U6J.cjs → chunk-JQ4I743L.cjs} +11 -11
  13. package/dist/{chunk-D5YY5U6J.cjs.map → chunk-JQ4I743L.cjs.map} +1 -1
  14. package/dist/{chunk-C6SCWXDP.js → chunk-MV67MBV3.js} +3 -3
  15. package/dist/{chunk-BBZ7AX5H.cjs → chunk-MWS25U4U.cjs} +10 -10
  16. package/dist/{chunk-BBZ7AX5H.cjs.map → chunk-MWS25U4U.cjs.map} +1 -1
  17. package/dist/{chunk-DDAT4RKX.js → chunk-ODR6A6FC.js} +24 -2
  18. package/dist/chunk-ODR6A6FC.js.map +1 -0
  19. package/dist/{chunk-GDF2R2ER.cjs → chunk-OXC72UIP.cjs} +120 -120
  20. package/dist/{chunk-GDF2R2ER.cjs.map → chunk-OXC72UIP.cjs.map} +1 -1
  21. package/dist/{chunk-RQ6RTBKF.cjs → chunk-RG6FNZUA.cjs} +65 -27
  22. package/dist/chunk-RG6FNZUA.cjs.map +1 -0
  23. package/dist/{chunk-5BTZOVDQ.js → chunk-RWCA2ZQK.js} +2 -2
  24. package/dist/{chunk-QF2X6PTD.js → chunk-TY2EB7VK.js} +58 -20
  25. package/dist/chunk-TY2EB7VK.js.map +1 -0
  26. package/dist/{chunk-VCQ3CTYK.js → chunk-ZYLQMCHW.js} +3 -3
  27. package/dist/components/chat/index.cjs +3 -3
  28. package/dist/components/chat/index.js +2 -2
  29. package/dist/components/contact/index.cjs +4 -4
  30. package/dist/components/contact/index.js +3 -3
  31. package/dist/components/docs/index.cjs +3 -3
  32. package/dist/components/docs/index.js +2 -2
  33. package/dist/components/embeds/index.cjs +4 -4
  34. package/dist/components/embeds/index.js +3 -3
  35. package/dist/components/faq/faq-section.d.ts.map +1 -1
  36. package/dist/components/faq/index.cjs +4 -4
  37. package/dist/components/faq/index.js +3 -3
  38. package/dist/components/faq-accordion.d.ts.map +1 -1
  39. package/dist/components/features/index.cjs +3 -3
  40. package/dist/components/features/index.js +2 -2
  41. package/dist/components/index.cjs +142 -142
  42. package/dist/components/index.js +7 -7
  43. package/dist/components/navigation/index.cjs +3 -3
  44. package/dist/components/navigation/index.js +2 -2
  45. package/dist/components/related-content/index.cjs +4 -4
  46. package/dist/components/related-content/index.js +3 -3
  47. package/dist/components/tickets/index.cjs +53 -53
  48. package/dist/components/tickets/index.js +4 -4
  49. package/dist/components/ui/index.cjs +3 -3
  50. package/dist/components/ui/index.js +2 -2
  51. package/dist/index.cjs +9 -3
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.js +8 -2
  54. package/dist/types/marketing.d.ts +1 -1
  55. package/dist/types/marketing.d.ts.map +1 -1
  56. package/dist/utils/faq-anchor.d.ts +51 -0
  57. package/dist/utils/faq-anchor.d.ts.map +1 -0
  58. package/dist/utils/index.cjs +23 -1
  59. package/dist/utils/index.cjs.map +1 -1
  60. package/dist/utils/index.d.ts +2 -0
  61. package/dist/utils/index.d.ts.map +1 -1
  62. package/dist/utils/index.js +21 -2
  63. package/dist/utils/index.js.map +1 -1
  64. package/dist/utils/list-url.d.ts.map +1 -1
  65. package/package.json +1 -1
  66. package/src/components/faq/faq-section.tsx +78 -26
  67. package/src/components/faq-accordion.tsx +8 -1
  68. package/src/types/marketing.ts +1 -0
  69. package/src/utils/faq-anchor.ts +70 -0
  70. package/src/utils/index.ts +7 -0
  71. package/src/utils/list-url.ts +1 -0
  72. package/dist/chunk-6N26CURS.cjs.map +0 -1
  73. package/dist/chunk-DDAT4RKX.js.map +0 -1
  74. package/dist/chunk-QF2X6PTD.js.map +0 -1
  75. package/dist/chunk-RQ6RTBKF.cjs.map +0 -1
  76. /package/dist/{chunk-YVB3VDIQ.js.map → chunk-7EYWERFT.js.map} +0 -0
  77. /package/dist/{chunk-FSBDVT6R.js.map → chunk-EFYXPR43.js.map} +0 -0
  78. /package/dist/{chunk-XQL4WDML.js.map → chunk-GJDXIVEQ.js.map} +0 -0
  79. /package/dist/{chunk-C6SCWXDP.js.map → chunk-MV67MBV3.js.map} +0 -0
  80. /package/dist/{chunk-5BTZOVDQ.js.map → chunk-RWCA2ZQK.js.map} +0 -0
  81. /package/dist/{chunk-VCQ3CTYK.js.map → chunk-ZYLQMCHW.js.map} +0 -0
@@ -1 +1 @@
1
- {"version":3,"file":"list-url.d.ts","sourceRoot":"","sources":["../../src/utils/list-url.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAWH;;;;iEAIiE;AACjE,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAEtE;AA4BD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,IAAI,SAAK,GAAG,MAAM,GAAG,IAAI,CAa5F"}
1
+ {"version":3,"file":"list-url.d.ts","sourceRoot":"","sources":["../../src/utils/list-url.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAWH;;;;iEAIiE;AACjE,wBAAgB,uBAAuB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAEtE;AA6BD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,IAAI,SAAK,GAAG,MAAM,GAAG,IAAI,CAa5F"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.286",
3
+ "version": "0.0.287",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -7,6 +7,7 @@ import { useSelfFetch } from '../../hooks/use-self-fetch'
7
7
  import { buildSuggestionUrl } from '../../utils/suggestion-url'
8
8
  import { serializeJsonLd } from '../../utils/common'
9
9
  import { scrollElementIntoView } from '../../utils/scroll-into-view'
10
+ import { faqSectionSlug, faqItemAnchor, parseFaqHash, type FaqHashTarget } from '../../utils/faq-anchor'
10
11
  import { cn } from '../../utils/cn'
11
12
  import { buildFaqJsonLdFromFaqs, type FaqSchemaOptions } from './json-ld'
12
13
  import { SECTION_HEADING_CLASS } from '../layout/page-heading'
@@ -59,19 +60,6 @@ function buildFaqsUrl(
59
60
  return buildSuggestionUrl('/api/faqs', { apiBaseUrl, entityType, entityId, count: minResults })
60
61
  }
61
62
 
62
- /** Stable, URL-safe anchor id for a category. Prefixed so it can't collide
63
- * with other in-page ids, and so a bare numeric/blank section still yields a
64
- * valid id. */
65
- function sectionSlug(section: string): string {
66
- return (
67
- 'faq-' +
68
- section
69
- .toLowerCase()
70
- .replace(/[^a-z0-9]+/g, '-')
71
- .replace(/^-+|-+$/g, '')
72
- )
73
- }
74
-
75
63
  interface FaqGroup {
76
64
  /** null → the uncategorized bucket: no heading, no jump pill, rendered last. */
77
65
  section: string | null
@@ -98,7 +86,7 @@ function groupFaqsBySection(faqs: Faq[]): FaqGroup[] {
98
86
  }
99
87
  let group = byName.get(name)
100
88
  if (!group) {
101
- group = { section: name, slug: sectionSlug(name), items: [] }
89
+ group = { section: name, slug: faqSectionSlug(name), items: [] }
102
90
  byName.set(name, group)
103
91
  order.push(name)
104
92
  }
@@ -113,6 +101,12 @@ function groupFaqsBySection(faqs: Faq[]): FaqGroup[] {
113
101
  * scroll uses, so a category jump lands below the header, not under it. */
114
102
  const FAQ_NAV_HEADER_OFFSET = 96
115
103
 
104
+ /** Map key for the uncategorized bucket — `group.slug` is null for it, so
105
+ * every per-group map (default-open ids, accordion keys) uses this sentinel
106
+ * to keep the lookup typed. */
107
+ const UNCATEGORIZED_KEY = '__uncategorized__'
108
+ const groupKey = (g: FaqGroup): string => g.slug ?? UNCATEGORIZED_KEY
109
+
116
110
  /**
117
111
  * Grouped FAQ layout: a category jump-nav above stacked `<h2>` category
118
112
  * sections (each its own accordion). Isolated into its own component so the
@@ -177,6 +171,54 @@ function GroupedFaqList({
177
171
  // eslint-disable-next-line react-hooks/exhaustive-deps
178
172
  }, [slugKey])
179
173
 
174
+ // ─── Hash dispatch — `/faqs#faq-item-<id>` or `/faqs#faq-<section-slug>` ──
175
+ // Tracks the current hash so:
176
+ // 1. an item-kind hash seeds `defaultOpenIds` on the matching accordion
177
+ // (auto-expands the cited question);
178
+ // 2. either kind triggers the cancellation-proof tween scroll with the
179
+ // sticky-header offset (native browser hash scroll runs once, ignores
180
+ // our offset — re-running the tween puts the target in the right spot).
181
+ // Listens to `hashchange` so back/forward replays the same behavior. SSR-
182
+ // safe: initial null state matches the server render; the first effect
183
+ // tick on the client updates it.
184
+ const [hashTarget, setHashTarget] = useState<FaqHashTarget | null>(null)
185
+ useEffect(() => {
186
+ const refresh = () => setHashTarget(parseFaqHash(window.location.hash))
187
+ refresh()
188
+ window.addEventListener('hashchange', refresh)
189
+ return () => window.removeEventListener('hashchange', refresh)
190
+ }, [])
191
+
192
+ // Per-group default-open set when the hash points at an item. The map key
193
+ // matches `groupKey(group)` so the render-time lookup is O(1) per group.
194
+ const defaultOpenByGroupKey = useMemo(() => {
195
+ if (hashTarget?.kind !== 'item') return null
196
+ const targetId = hashTarget.rawId
197
+ const result = new Map<string, (string | number)[]>()
198
+ for (const group of groups) {
199
+ const hit = group.items.find((i) => String(i.id) === targetId)
200
+ if (hit) result.set(groupKey(group), [hit.id])
201
+ }
202
+ return result.size > 0 ? result : null
203
+ }, [groups, hashTarget])
204
+
205
+ // Accordion is uncontrolled — `defaultOpenIds` is only consumed at mount,
206
+ // so a new item hash needs a remount to honor it. Keying off the item-id
207
+ // suffix triggers exactly the remount we need (and stays stable when the
208
+ // hash points at a section, so category navigation never disturbs the
209
+ // accordion's open state).
210
+ const accordionKeySuffix =
211
+ hashTarget?.kind === 'item' ? `item:${hashTarget.rawId}` : 'default'
212
+
213
+ useEffect(() => {
214
+ if (!hashTarget) return
215
+ const elId =
216
+ hashTarget.kind === 'item' ? faqItemAnchor(hashTarget.rawId) : hashTarget.slug
217
+ const el = document.getElementById(elId)
218
+ if (el) scrollElementIntoView(el, { headerOffset: FAQ_NAV_HEADER_OFFSET })
219
+ if (hashTarget.kind === 'section') setActiveSlug(hashTarget.slug)
220
+ }, [hashTarget])
221
+
180
222
  const handleJump = useCallback(
181
223
  (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
182
224
  e.preventDefault()
@@ -215,18 +257,28 @@ function GroupedFaqList({
215
257
  </nav>
216
258
  )}
217
259
  <div className="space-y-10">
218
- {groups.map((group) => (
219
- <section
220
- key={group.slug ?? 'faq-uncategorized'}
221
- id={group.slug ?? undefined}
222
- className="scroll-mt-24 space-y-4"
223
- >
224
- {group.section && (
225
- <CategoryHeading className={SECTION_HEADING_CLASS}>{group.section}</CategoryHeading>
226
- )}
227
- <FaqAccordion items={group.items} />
228
- </section>
229
- ))}
260
+ {groups.map((group) => {
261
+ const key = groupKey(group)
262
+ return (
263
+ <section
264
+ key={key}
265
+ id={group.slug ?? undefined}
266
+ className="scroll-mt-24 space-y-4"
267
+ >
268
+ {group.section && (
269
+ <CategoryHeading className={SECTION_HEADING_CLASS}>{group.section}</CategoryHeading>
270
+ )}
271
+ <FaqAccordion
272
+ // Re-key on item-hash changes so the remount picks up the new
273
+ // `defaultOpenIds` (the accordion is uncontrolled). Stable for
274
+ // section hashes — category navigation doesn't disturb state.
275
+ key={`${key}:${accordionKeySuffix}`}
276
+ items={group.items}
277
+ defaultOpenIds={defaultOpenByGroupKey?.get(key)}
278
+ />
279
+ </section>
280
+ )
281
+ })}
230
282
  </div>
231
283
  </div>
232
284
  )
@@ -3,6 +3,7 @@
3
3
  import React, { useRef, useState, useEffect, useCallback } from 'react'
4
4
  import { ChevronButton } from './ui/chevron-button'
5
5
  import { cn } from "../utils/cn"
6
+ import { faqItemAnchor } from "../utils/faq-anchor"
6
7
 
7
8
  export interface FaqItem {
8
9
  id: number | string
@@ -60,7 +61,13 @@ export function FaqAccordion({ items, defaultOpenIds = [] }: FaqAccordionProps)
60
61
  return (
61
62
  <div
62
63
  key={item.id}
63
- className={cn('group transition-colors hover:bg-[#1E1E1E]', isOpen ? 'bg-ods-bg' : 'bg-transparent')}
64
+ // Per-row anchor chat citation chips (`/faqs#faq-item-<id>`) land
65
+ // here via native browser hash scroll AND via `FaqSection`'s tween
66
+ // dispatch. `scroll-mt-24` keeps the row header below the 96px
67
+ // sticky nav offset (matches `<section>`'s scroll-margin for
68
+ // category anchors).
69
+ id={faqItemAnchor(item.id)}
70
+ className={cn('group scroll-mt-24 transition-colors hover:bg-[#1E1E1E]', isOpen ? 'bg-ods-bg' : 'bg-transparent')}
64
71
  >
65
72
  {/* Header */}
66
73
  <div
@@ -347,6 +347,7 @@ export type ContentSourceType =
347
347
  | 'investor_update' // Investor updates
348
348
  | 'onboarding_guide' // Onboarding guides (lives on openframe platform)
349
349
  | 'what_i_shipped' // What I Shipped employee check-ins (lives on people-hub)
350
+ | 'faq' // FAQ Q&A pair (single-page /faqs index; deep-link by category anchor)
350
351
  | 'from_scratch';
351
352
 
352
353
  export type URLInjectionPreference = 'none' | 'in_post' | 'as_comment';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * FAQ anchor SSOTs — TWO complementary deep-link kinds on the `/faqs` page,
3
+ * both rendered straight onto the DOM and recognised by ONE parser. Keeping
4
+ * the format helpers + parser in one file means the page, the chat RAG
5
+ * mapper, and any future consumer can't drift on what a hash means.
6
+ *
7
+ * `/faqs#faq-pricing` → category section header (jump-nav pills,
8
+ * scroll-spy)
9
+ * `/faqs#faq-item-42` → individual question (chat citation chips —
10
+ * auto-expands the row + scrolls to it)
11
+ *
12
+ * Reserved namespaces — `faq-item-<digits>` is the item shape; everything
13
+ * else starting with `faq-` is a section slug. `faqSectionSlug` lowercases
14
+ * + dash-collapses + dash-trims, so a section name would only collide with
15
+ * the item shape if it slugified to `faq-item-<digits-only>` (e.g. a
16
+ * category called "Item 42") — none of the 21 production sections do, and
17
+ * `parseFaqHash`'s regex is digits-only so future word-suffixed names like
18
+ * "Item Whatever" (slugifies to `faq-item-whatever`) are also safe.
19
+ */
20
+
21
+ /** Stable, URL-safe anchor id for a category. Prefixed with `faq-` so it
22
+ * can't collide with other in-page ids, and so a bare numeric/blank
23
+ * section still yields a valid id. The helper assumes the caller has
24
+ * already verified the section is a non-blank string (matches
25
+ * `faq-section.tsx`'s `groupFaqsBySection` precondition). */
26
+ export function faqSectionSlug(section: string): string {
27
+ return (
28
+ 'faq-' +
29
+ section
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, '-')
32
+ .replace(/^-+|-+$/g, '')
33
+ )
34
+ }
35
+
36
+ /** Stable anchor for an individual FAQ row. Rendered as the row container's
37
+ * `id` attribute by `FaqAccordion`; consumed by `FaqSection` (auto-open +
38
+ * auto-scroll on mount) AND by the hub's RAG mapper so chat citations
39
+ * deep-link to the specific question, not just its category. The id is
40
+ * stringified verbatim — the FAQ schema uses integer PKs so `parseFaqHash`'s
41
+ * digits-only regex always matches a real row. */
42
+ export function faqItemAnchor(id: number | string): string {
43
+ return `faq-item-${id}`
44
+ }
45
+
46
+ /** Discriminated parse of a `/faqs#…` hash. Returns null for an empty,
47
+ * missing, or unrecognised hash so callers can early-out without
48
+ * scattering string parsing across the file.
49
+ *
50
+ * parseFaqHash('#faq-item-42') → { kind: 'item', rawId: '42' }
51
+ * parseFaqHash('#faq-pricing') → { kind: 'section', slug: 'faq-pricing' }
52
+ * parseFaqHash('#anything-else') → null
53
+ *
54
+ * `rawId` is the matched digit run as a string — the caller compares it
55
+ * to `String(item.id)` so coercion stays at the comparison site. */
56
+ export type FaqHashTarget =
57
+ | { kind: 'item'; rawId: string }
58
+ | { kind: 'section'; slug: string }
59
+
60
+ const FAQ_ITEM_HASH_RE = /^faq-item-(\d+)$/
61
+
62
+ export function parseFaqHash(hash: string | null | undefined): FaqHashTarget | null {
63
+ if (!hash) return null
64
+ const trimmed = hash.replace(/^#/, '')
65
+ if (!trimmed) return null
66
+ const itemMatch = FAQ_ITEM_HASH_RE.exec(trimmed)
67
+ if (itemMatch) return { kind: 'item', rawId: itemMatch[1] }
68
+ if (trimmed.startsWith('faq-')) return { kind: 'section', slug: trimmed }
69
+ return null
70
+ }
@@ -253,6 +253,13 @@ export {
253
253
  // Pure + server-safe (the hub imports it server-side from this barrel).
254
254
  export { buildListUrl, canonicalContentRefType } from './list-url'
255
255
 
256
+ // FAQ anchor SSOTs — section (`faq-<slug>`) AND item (`faq-item-<id>`)
257
+ // formats plus the parser, all rendered by `FaqSection`/`FaqAccordion`
258
+ // and recognised by the hub's RAG mapper. One algo per kind, one parser,
259
+ // zero drift across page + chat + future consumers.
260
+ export { faqSectionSlug, faqItemAnchor, parseFaqHash } from './faq-anchor'
261
+ export type { FaqHashTarget } from './faq-anchor'
262
+
256
263
  // Content-ref group registry (labels/order/layout per rail type) + list-API
257
264
  // response normalizers + the shared suggestion-fetch URL composer — all
258
265
  // pure + server-safe; the hub re-exports these from its config/util shims.
@@ -70,6 +70,7 @@ const BUILDERS: Record<string, (ids: string[], base: string) => string> = {
70
70
  product_release: (ids, b) => `${b}/api/releases?ids=${ids.join(',')}&limit=${ids.length}`,
71
71
  customer_interview: (ids, b) => `${b}/api/customer-interviews?ids=${ids.join(',')}&limit=${ids.length}`,
72
72
  investor_update: (ids, b) => `${b}/api/investor-updates?ids=${ids.join(',')}&limit=${ids.length}`,
73
+ faq: (ids, b) => `${b}/api/faqs?ids=${ids.join(',')}&limit=${ids.length}`,
73
74
  }
74
75
 
75
76
  /**