@flamingo-stack/openframe-frontend-core 0.0.209 → 0.0.210-snapshot.20260528032637
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.
- package/dist/{chunk-HZT4YU33.js → chunk-3E5ANY55.js} +49 -26
- package/dist/chunk-3E5ANY55.js.map +1 -0
- package/dist/{chunk-RFSBDUXD.js → chunk-5BNWGK6D.js} +2 -2
- package/dist/{chunk-6RZYJICV.cjs → chunk-P5EE2VJX.cjs} +1 -1
- package/dist/chunk-P5EE2VJX.cjs.map +1 -0
- package/dist/{chunk-GIKD2LWD.js → chunk-QKFBZLIR.js} +2 -2
- package/dist/{chunk-Y7IXGY7T.cjs → chunk-VTUIMMHO.cjs} +24 -24
- package/dist/{chunk-Y7IXGY7T.cjs.map → chunk-VTUIMMHO.cjs.map} +1 -1
- package/dist/{chunk-SJQLTQC7.cjs → chunk-WI76ZUBE.cjs} +57 -34
- package/dist/chunk-WI76ZUBE.cjs.map +1 -0
- package/dist/{chunk-S2B3UFVP.cjs → chunk-ZFBLC5GV.cjs} +17 -17
- package/dist/{chunk-S2B3UFVP.cjs.map → chunk-ZFBLC5GV.cjs.map} +1 -1
- package/dist/{chunk-7L4DWM7P.js → chunk-ZG2YY5E7.js} +1 -1
- package/dist/chunk-ZG2YY5E7.js.map +1 -0
- package/dist/components/chat/entity-cards/blog-card.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat-identity.d.ts +3 -3
- package/dist/components/chat/hooks/use-chat-identity.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/footer-waitlist-button.d.ts +21 -2
- package/dist/components/footer-waitlist-button.d.ts.map +1 -1
- package/dist/components/index.cjs +87 -91
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +12 -16
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/navigation/sticky-section-nav.d.ts.map +1 -1
- package/dist/components/tickets/help-center-card.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +139 -98
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +95 -54
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/tickets/ticket-row.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/contexts/chat-runtime-context.d.ts +6 -3
- package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
- package/dist/contexts/index.cjs +2 -2
- package/dist/contexts/index.js +1 -1
- package/dist/index.cjs +5 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -2
- package/dist/utils/index.cjs +10 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +10 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/scroll-into-view.d.ts +63 -0
- package/dist/utils/scroll-into-view.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/chat/entity-cards/blog-card.tsx +25 -16
- package/src/components/chat/hooks/use-chat-identity.ts +8 -7
- package/src/components/footer-waitlist-button.tsx +33 -16
- package/src/components/navigation/sticky-section-nav.tsx +6 -4
- package/src/components/tickets/help-center-card.tsx +55 -1
- package/src/components/tickets/help-center-list.tsx +9 -1
- package/src/components/tickets/ticket-detail-drawer.tsx +19 -4
- package/src/components/tickets/ticket-row.tsx +30 -19
- package/src/contexts/chat-runtime-context.tsx +6 -3
- package/src/stories/EmbeddableChat.stories.tsx +1 -1
- package/src/utils/index.ts +12 -0
- package/src/utils/scroll-into-view.ts +74 -0
- package/dist/chunk-6RZYJICV.cjs.map +0 -1
- package/dist/chunk-7L4DWM7P.js.map +0 -1
- package/dist/chunk-HZT4YU33.js.map +0 -1
- package/dist/chunk-SJQLTQC7.cjs.map +0 -1
- /package/dist/{chunk-RFSBDUXD.js.map → chunk-5BNWGK6D.js.map} +0 -0
- /package/dist/{chunk-GIKD2LWD.js.map → chunk-QKFBZLIR.js.map} +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scrollElementIntoView` — canonical "smooth scroll element to top
|
|
3
|
+
* of viewport, account for sticky chrome, optionally adjust for
|
|
4
|
+
* known layout shifts" helper.
|
|
5
|
+
*
|
|
6
|
+
* Before this util existed, ~3 different call sites in the lib + hub
|
|
7
|
+
* had the same 5-line snippet copy-pasted with subtle differences:
|
|
8
|
+
*
|
|
9
|
+
* - `useUnifiedNav` same-URL re-scroll branch (hub)
|
|
10
|
+
* - `<HelpCenterCard>` click-to-expand (lib, with cross-row
|
|
11
|
+
* layout-shift adjustment)
|
|
12
|
+
* - Future ticket-row / docs anchor scrolls
|
|
13
|
+
*
|
|
14
|
+
* The canonical pattern is `window.scrollTo({top, behavior:'smooth'})`
|
|
15
|
+
* with a pre-computed pixel target — NOT `element.scrollIntoView()`.
|
|
16
|
+
* Pre-computing the target lets the browser run a clean uninterrupted
|
|
17
|
+
* smooth animation to a fixed pixel value. `scrollIntoView` re-targets
|
|
18
|
+
* continuously as the page layout shifts during the animation, which
|
|
19
|
+
* causes visible jitter when content above the target is also moving
|
|
20
|
+
* (a sibling collapsing, an async image loading, …).
|
|
21
|
+
*
|
|
22
|
+
* The `adjustTargetY` callback is the escape hatch for cases where
|
|
23
|
+
* the consumer KNOWS about an upcoming layout shift and can compute
|
|
24
|
+
* the correct FINAL target before the animation starts. Example: in
|
|
25
|
+
* `HelpCenterCard`, clicking row B while row A above is currently
|
|
26
|
+
* expanded — A's drawer collapses simultaneously with B's expansion,
|
|
27
|
+
* shifting B's tile up by A's drawer height. The consumer passes
|
|
28
|
+
* `adjustTargetY: raw => raw - getAboveDrawerHeight()` and the
|
|
29
|
+
* browser smooth-scrolls to the post-collapse position directly.
|
|
30
|
+
*/
|
|
31
|
+
export interface ScrollElementIntoViewOptions {
|
|
32
|
+
/** Pixels to subtract from the target element's `top` so it lands
|
|
33
|
+
* BELOW sticky chrome. Defaults to 0. Pass `96` (matches
|
|
34
|
+
* `scroll-mt-24`) for the standard hub header offset. */
|
|
35
|
+
headerOffset?: number;
|
|
36
|
+
/** Scroll animation style. Defaults to `'smooth'`. Use `'instant'`
|
|
37
|
+
* for imperative jumps where animation would feel laggy (deep
|
|
38
|
+
* link land, programmatic focus moves). */
|
|
39
|
+
behavior?: ScrollBehavior;
|
|
40
|
+
/** Optional adjustment applied to the computed pixel target. The
|
|
41
|
+
* callback receives the "raw" Y (`element.top + scrollY -
|
|
42
|
+
* headerOffset`) and returns the FINAL pixel target. Use this
|
|
43
|
+
* when the caller knows about a layout shift that will happen
|
|
44
|
+
* between the call and the animation completing — the browser's
|
|
45
|
+
* smooth-scroll commits to a single pixel value, so providing the
|
|
46
|
+
* post-shift target up front lands the element correctly even as
|
|
47
|
+
* content above it moves. */
|
|
48
|
+
adjustTargetY?: (rawTargetY: number) => number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Scroll the page so `target` lands at the top of the viewport,
|
|
52
|
+
* accounting for sticky chrome via `headerOffset`. Returns void; the
|
|
53
|
+
* scroll runs async via the browser's smooth-scroll engine.
|
|
54
|
+
*
|
|
55
|
+
* Accepts:
|
|
56
|
+
* - `HTMLElement` — direct reference (most common from `useRef`).
|
|
57
|
+
* - `null` / `undefined` — no-op so callers can pass refs without
|
|
58
|
+
* defensive branching.
|
|
59
|
+
*
|
|
60
|
+
* SSR-safe: short-circuits when `window` is undefined.
|
|
61
|
+
*/
|
|
62
|
+
export declare function scrollElementIntoView(target: HTMLElement | null | undefined, options?: ScrollElementIntoViewOptions): void;
|
|
63
|
+
//# sourceMappingURL=scroll-into-view.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scroll-into-view.d.ts","sourceRoot":"","sources":["../../src/utils/scroll-into-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,WAAW,4BAA4B;IAC3C;;8DAE0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;gDAE4C;IAC5C,QAAQ,CAAC,EAAE,cAAc,CAAA;IACzB;;;;;;;kCAO8B;IAC9B,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAA;CAC/C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,GAAE,4BAAiC,GACzC,IAAI,CAON"}
|
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import React, { useState } from 'react'
|
|
22
|
+
import { Eye } from 'lucide-react'
|
|
22
23
|
import Image from '../../../embed-shims/next-image'
|
|
23
24
|
import { StatusBadge } from '../../ui/status-badge'
|
|
24
25
|
import { cn } from '../../../utils/cn'
|
|
@@ -235,22 +236,30 @@ export function BlogCard({
|
|
|
235
236
|
</p>
|
|
236
237
|
</div>
|
|
237
238
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
239
|
+
<div className="mt-auto flex items-center justify-between gap-2 text-sm text-ods-text-secondary">
|
|
240
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
241
|
+
{post.author_avatar ? (
|
|
242
|
+
<Image
|
|
243
|
+
src={post.author_avatar}
|
|
244
|
+
alt={post.author_name || ''}
|
|
245
|
+
width={32}
|
|
246
|
+
height={32}
|
|
247
|
+
className="rounded-full shrink-0"
|
|
248
|
+
unoptimized
|
|
249
|
+
/>
|
|
250
|
+
) : null}
|
|
251
|
+
<span className="truncate">
|
|
252
|
+
{post.author_name || 'Anonymous'}
|
|
253
|
+
{dateStr ? <> · {dateStr}</> : null}
|
|
254
|
+
</span>
|
|
255
|
+
</div>
|
|
256
|
+
<div
|
|
257
|
+
className="flex items-center gap-1 shrink-0"
|
|
258
|
+
aria-label={`View count: ${(post.view_count ?? 0).toLocaleString('en-US')} views`}
|
|
259
|
+
>
|
|
260
|
+
<Eye className="w-4 h-4 shrink-0" />
|
|
261
|
+
<span>{(post.view_count ?? 0).toLocaleString('en-US')}</span>
|
|
262
|
+
</div>
|
|
254
263
|
</div>
|
|
255
264
|
</div>
|
|
256
265
|
</a>
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* (attachment button, drawer composer, /tickets gate) on chat-side
|
|
9
9
|
* identity tiers WITHOUT sending a chat message first.
|
|
10
10
|
*
|
|
11
|
-
* Server-side parity: the
|
|
12
|
-
*
|
|
11
|
+
* Server-side parity: the host's identity endpoint (hub default
|
|
12
|
+
* `/api/auth/identity`, override via `runtime.endpoints.identityUrl`)
|
|
13
|
+
* runs the same 3-tier `requireChatAuth` chain the chat itself uses.
|
|
13
14
|
* `attachmentsEnabled` is computed server-side as
|
|
14
15
|
* `authTier !== 'anon' AND isSelfScopedSource(source)` — single
|
|
15
16
|
* source of truth, consumers don't combine the fields themselves.
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
* 3. The fetch is cheap and short — no perf justification for a
|
|
28
29
|
* cache layer
|
|
29
30
|
*
|
|
30
|
-
* Endpoint URL: read from `useRequiredChatRuntime().endpoints.
|
|
31
|
+
* Endpoint URL: read from `useRequiredChatRuntime().endpoints.identityUrl`
|
|
31
32
|
* so embedded apps with their own reverse-proxy topology can override.
|
|
32
33
|
*/
|
|
33
34
|
|
|
@@ -37,9 +38,9 @@ import { chatAuthedFetch } from '../utils/chat-authed-fetch'
|
|
|
37
38
|
import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
* Wire-shape for the
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* Wire-shape for the identity route response. Mirrors the hub's
|
|
42
|
+
* `ChatIdentityResponse` (in `app/api/auth/identity/route.ts`) —
|
|
43
|
+
* kept in sync there. Lib-side declaration so the chat panel can
|
|
43
44
|
* compile without depending on hub-internal types.
|
|
44
45
|
*/
|
|
45
46
|
export interface ChatIdentityResponse {
|
|
@@ -87,7 +88,7 @@ const ANON_DEFAULTS: ChatIdentityResponse = {
|
|
|
87
88
|
|
|
88
89
|
export function useChatIdentity(): ChatIdentitySurface {
|
|
89
90
|
const runtime = useRequiredChatRuntime()
|
|
90
|
-
const url = runtime.endpoints.
|
|
91
|
+
const url = runtime.endpoints.identityUrl
|
|
91
92
|
// `getChatProxyAuth()` reads localStorage every render. If the user
|
|
92
93
|
// pastes bearer creds mid-session (via the `/debug` creds bar),
|
|
93
94
|
// their email arrives here and the effect's dep changes → refetch.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useRouter } from '../embed-shims/next-navigation';
|
|
4
|
+
import { useChatRuntime } from '../contexts/chat-runtime-context';
|
|
4
5
|
import { useCallback } from 'react';
|
|
5
6
|
import { OpenFrameLogo } from './icons';
|
|
6
7
|
import { Button } from './ui/button';
|
|
@@ -11,27 +12,43 @@ export interface FooterWaitlistButtonProps {
|
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Small wrapper around JoinWaitlistButton for use inside the footer.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
*
|
|
16
|
+
* Routes through the host's unified-navigation hook
|
|
17
|
+
* (`runtime.navigation.navigate`) when a `ChatRuntimeContext` is
|
|
18
|
+
* mounted — that's the same path EVERY other in-app navigation
|
|
19
|
+
* surface uses (source chips, inline cards, search-autocomplete,
|
|
20
|
+
* action cards). One rule, one decision tree across the whole app.
|
|
21
|
+
* The hub's `HubRuntimeProvider` wires `navigate` to its `useUnifiedNav`
|
|
22
|
+
* helper, so this button picks up cross-platform new-tab decisions,
|
|
23
|
+
* same-URL re-scroll handling, embed-mode short-circuiting, and any
|
|
24
|
+
* future host-side nav rules for free.
|
|
25
|
+
*
|
|
26
|
+
* Falls back to the embed-shim's `router.push` when no runtime is
|
|
27
|
+
* mounted (third-party embedders who haven't set up
|
|
28
|
+
* `ChatRuntimeContext` — the lib stays usable without forcing them
|
|
29
|
+
* to wire the full chat-runtime).
|
|
30
|
+
*
|
|
31
|
+
* Target URL: `/waitlist#top`. `#top` is the canonical "scroll to
|
|
32
|
+
* page top" anchor — the destination page has an explicit
|
|
33
|
+
* `<div id="top">` at the top of `<main>` so native browser anchor
|
|
34
|
+
* scroll works in every browser regardless of the HTML5 magic-anchor
|
|
35
|
+
* behavior.
|
|
16
36
|
*/
|
|
17
37
|
export function FooterWaitlistButton({ className }: FooterWaitlistButtonProps) {
|
|
18
38
|
const router = useRouter();
|
|
19
|
-
const
|
|
39
|
+
const runtime = useChatRuntime();
|
|
20
40
|
|
|
21
41
|
const handleClick = useCallback(() => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}, 400);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
42
|
+
const href = '/waitlist#top';
|
|
43
|
+
// Prefer the host's unified-nav callback (hub-wired
|
|
44
|
+
// `useUnifiedNav`). Falls back to the embed-shim's router when
|
|
45
|
+
// the host hasn't provided one.
|
|
46
|
+
if (runtime?.navigation?.navigate) {
|
|
47
|
+
const handled = runtime.navigation.navigate({ href });
|
|
48
|
+
if (handled) return;
|
|
32
49
|
}
|
|
33
|
-
router.push(
|
|
34
|
-
}, [
|
|
50
|
+
router.push(href);
|
|
51
|
+
}, [router, runtime]);
|
|
35
52
|
|
|
36
53
|
return (
|
|
37
54
|
<Button
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useEffect, useState, useCallback, useRef } from 'react'
|
|
4
4
|
import { cn } from '../../utils'
|
|
5
|
+
import { scrollElementIntoView } from '../../utils/scroll-into-view'
|
|
5
6
|
|
|
6
7
|
export interface StickyNavSection {
|
|
7
8
|
id: string
|
|
@@ -97,7 +98,10 @@ export function useSectionNavigation(
|
|
|
97
98
|
const isScrollingFromClick = useRef(false)
|
|
98
99
|
const { offset = 100 } = options || {}
|
|
99
100
|
|
|
100
|
-
// Handle click -
|
|
101
|
+
// Handle click - scroll to the element via the canonical helper.
|
|
102
|
+
// The `offset` prop maps to `headerOffset` (sticky chrome above the
|
|
103
|
+
// section nav); same smooth-scroll mechanics every other anchor
|
|
104
|
+
// surface in the app uses.
|
|
101
105
|
const handleSectionClick = useCallback((sectionId: string) => {
|
|
102
106
|
const targetElement = document.getElementById(sectionId)
|
|
103
107
|
if (!targetElement) return
|
|
@@ -106,9 +110,7 @@ export function useSectionNavigation(
|
|
|
106
110
|
isScrollingFromClick.current = true
|
|
107
111
|
setActiveSection(sectionId)
|
|
108
112
|
|
|
109
|
-
|
|
110
|
-
const top = targetElement.offsetTop - offset
|
|
111
|
-
window.scrollTo({ top, behavior: 'smooth' })
|
|
113
|
+
scrollElementIntoView(targetElement, { headerOffset: offset })
|
|
112
114
|
|
|
113
115
|
// Allow scroll spy again after scroll completes
|
|
114
116
|
setTimeout(() => {
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
* is a SIBLING of the toggle button, not nested inside it.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { useCallback, useRef } from 'react'
|
|
18
19
|
import { StatusBadge, type StatusBadgeProps } from '../ui'
|
|
19
20
|
import { formatRelativeTime } from '../../utils/date-utils'
|
|
21
|
+
import { scrollElementIntoView } from '../../utils/scroll-into-view'
|
|
20
22
|
import { getStatusColorScheme } from '../chat/utils/agent-status-message'
|
|
21
23
|
import { DevCardRowContent } from '../shared/dev-section/dev-card-row'
|
|
22
24
|
import {
|
|
@@ -26,6 +28,23 @@ import {
|
|
|
26
28
|
import type { AnyTicket } from './types'
|
|
27
29
|
import { isOptimistic } from './types'
|
|
28
30
|
|
|
31
|
+
/** Sticky page-chrome offset, applied two ways from this ONE constant:
|
|
32
|
+
*
|
|
33
|
+
* 1. As `scrollMarginTop` inline style on the wrapper — so any
|
|
34
|
+
* anchor-driven or `scrollIntoView()`-driven scroll (browser
|
|
35
|
+
* `#hash` navigation, Tab-focus into the card) lands BELOW the
|
|
36
|
+
* sticky header.
|
|
37
|
+
* 2. As `headerOffset` passed to `scrollElementIntoView(...)` — for
|
|
38
|
+
* the click-to-expand `window.scrollTo` path, which pre-computes
|
|
39
|
+
* its target pixel and ignores CSS `scroll-margin-top`.
|
|
40
|
+
*
|
|
41
|
+
* Single source of truth: change 96 here and BOTH paths follow. The
|
|
42
|
+
* previous code combined a `scroll-mt-24` (=96px) Tailwind class
|
|
43
|
+
* with this constant — two declarations, one comment binding them,
|
|
44
|
+
* drift hazard. Now there's nothing to keep in sync.
|
|
45
|
+
*/
|
|
46
|
+
const STICKY_HEADER_OFFSET_PX = 96
|
|
47
|
+
|
|
29
48
|
export interface HelpCenterCardProps {
|
|
30
49
|
ticket: AnyTicket
|
|
31
50
|
expanded: boolean
|
|
@@ -72,6 +91,39 @@ export function HelpCenterCard({
|
|
|
72
91
|
const isExpandable = !optimistic
|
|
73
92
|
const isExpanded = expanded && isExpandable
|
|
74
93
|
|
|
94
|
+
// Scroll-on-click — delegates to the canonical `scrollElementIntoView`
|
|
95
|
+
// helper with a cross-row layout-shift `adjustTargetY` callback. The
|
|
96
|
+
// helper owns the smooth-scroll mechanics + sticky-chrome offset; we
|
|
97
|
+
// pass the consumer-specific knowledge ("a sibling drawer above me
|
|
98
|
+
// is about to collapse — subtract its height from the target Y").
|
|
99
|
+
//
|
|
100
|
+
// Cross-row gotcha: if ANOTHER row above this one is currently
|
|
101
|
+
// expanded, its drawer collapses simultaneously with our toggle.
|
|
102
|
+
// The collapse shrinks the page above our row → our final Y is
|
|
103
|
+
// HIGHER than the current `rect.top`. By pre-subtracting the
|
|
104
|
+
// collapsing drawer's height we land at the post-shift position
|
|
105
|
+
// cleanly, without scrollIntoView's mid-animation drift.
|
|
106
|
+
const rowRef = useRef<HTMLDivElement | null>(null)
|
|
107
|
+
const handleClick = useCallback(() => {
|
|
108
|
+
onToggle(ticket.id)
|
|
109
|
+
scrollElementIntoView(rowRef.current, {
|
|
110
|
+
headerOffset: STICKY_HEADER_OFFSET_PX,
|
|
111
|
+
adjustTargetY: (raw) => {
|
|
112
|
+
if (!rowRef.current) return raw
|
|
113
|
+
const expandedDrawer = document.querySelector(
|
|
114
|
+
'div[id^="help-center-drawer-"]',
|
|
115
|
+
)
|
|
116
|
+
if (!(expandedDrawer instanceof HTMLElement)) return raw
|
|
117
|
+
const drawerRect = expandedDrawer.getBoundingClientRect()
|
|
118
|
+
const myRect = rowRef.current.getBoundingClientRect()
|
|
119
|
+
// Only adjust when the drawer is ABOVE us. Drawers below us
|
|
120
|
+
// don't shift our position when they collapse.
|
|
121
|
+
if (drawerRect.bottom > myRect.top) return raw
|
|
122
|
+
return raw - drawerRect.height
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
}, [onToggle, ticket.id])
|
|
126
|
+
|
|
75
127
|
const rightBadges = (
|
|
76
128
|
<>
|
|
77
129
|
<StatusBadge
|
|
@@ -93,12 +145,14 @@ export function HelpCenterCard({
|
|
|
93
145
|
|
|
94
146
|
return (
|
|
95
147
|
<div
|
|
148
|
+
ref={rowRef}
|
|
149
|
+
style={{ scrollMarginTop: STICKY_HEADER_OFFSET_PX }}
|
|
96
150
|
className={`border-b border-ods-border last:border-b-0 ${optimistic ? 'opacity-60' : ''}`}
|
|
97
151
|
aria-busy={optimistic || undefined}
|
|
98
152
|
>
|
|
99
153
|
<button
|
|
100
154
|
type="button"
|
|
101
|
-
onClick={isExpandable ?
|
|
155
|
+
onClick={isExpandable ? handleClick : undefined}
|
|
102
156
|
disabled={!isExpandable}
|
|
103
157
|
aria-expanded={isExpandable ? isExpanded : undefined}
|
|
104
158
|
aria-controls={isExpanded ? `help-center-drawer-${ticket.id}` : undefined}
|
|
@@ -264,7 +264,15 @@ function HelpCenterListAuthed({
|
|
|
264
264
|
/>
|
|
265
265
|
)
|
|
266
266
|
) : (
|
|
267
|
-
|
|
267
|
+
// `overflow-clip` (NOT `overflow-hidden`) — both visually
|
|
268
|
+
// clip the rounded corners, but `hidden` makes the element
|
|
269
|
+
// a "scroll container" per CSSOM spec, which causes
|
|
270
|
+
// `scrollIntoView` calls inside (`<HelpCenterCard>` click
|
|
271
|
+
// handlers) to try scrolling THIS div (can't, overflow
|
|
272
|
+
// hidden) instead of bubbling up to the window. `clip`
|
|
273
|
+
// keeps the visual clip but NOT the scroll-container
|
|
274
|
+
// status, so click-to-scroll actually moves the page.
|
|
275
|
+
<div className="bg-ods-card border border-ods-border rounded-[6px] overflow-clip w-full">
|
|
268
276
|
{merged.map((ticket) => (
|
|
269
277
|
<HelpCenterCard
|
|
270
278
|
key={ticket.id}
|
|
@@ -212,9 +212,16 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
|
|
|
212
212
|
return (
|
|
213
213
|
<div className="bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full">
|
|
214
214
|
{/* Customer-authored description + any legacy `---`-joined
|
|
215
|
-
comments.
|
|
216
|
-
|
|
217
|
-
|
|
215
|
+
comments. Always rendered ABOVE the engagement timeline as
|
|
216
|
+
"Original message" because the server's intake-burst filter
|
|
217
|
+
(see `filterCustomerVisibleTimeline` in
|
|
218
|
+
`hubspot-conversations-utils.ts`) drops the customer's first
|
|
219
|
+
message from engagements when it was part of the HubSpot
|
|
220
|
+
Custom Channel bot intake — bodyTurns IS the canonical
|
|
221
|
+
original for those tickets. For tickets created without bot
|
|
222
|
+
intake (admin-created, email channel) bodyTurns shows the
|
|
223
|
+
manually-entered description and engagements show subsequent
|
|
224
|
+
replies — same flow, no duplication. */}
|
|
218
225
|
{bodyTurns.map((turn, i) => {
|
|
219
226
|
const isResolution = turn.startsWith('[Resolution]')
|
|
220
227
|
const role =
|
|
@@ -315,11 +322,19 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
|
|
|
315
322
|
avatarSrc = undefined
|
|
316
323
|
}
|
|
317
324
|
|
|
325
|
+
// Role label: every engagement is a customer-visible
|
|
326
|
+
// Conversations message (customer ↔ agent on the Custom
|
|
327
|
+
// Channel). There are no internal Notes on this surface
|
|
328
|
+
// anymore — the read path explicitly filters them. So
|
|
329
|
+
// "Reply" for BOTH sides. The previous "Note" label for
|
|
330
|
+
// support bubbles was a legacy artifact from when Notes
|
|
331
|
+
// were rendered and made customers think their support
|
|
332
|
+
// engineer was leaving internal comments on their ticket.
|
|
318
333
|
return (
|
|
319
334
|
<ConversationCardRow
|
|
320
335
|
key={eng.id}
|
|
321
336
|
author={author}
|
|
322
|
-
role=
|
|
337
|
+
role="Reply"
|
|
323
338
|
avatarSrc={avatarSrc}
|
|
324
339
|
timestamp={eng.createdAt}
|
|
325
340
|
body={stripAttachmentsPreamble(eng.body ?? '')}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* rendered only when this row is the expanded one.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import { useCallback, useRef } from 'react'
|
|
22
22
|
import {
|
|
23
23
|
Collapsible,
|
|
24
24
|
CollapsibleContent,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type ChatTicketItemData,
|
|
29
29
|
} from '../chat/entity-cards/chat-ticket-item'
|
|
30
30
|
import { formatRelativeTime } from '../../utils/date-utils'
|
|
31
|
+
import { scrollElementIntoView } from '../../utils/scroll-into-view'
|
|
31
32
|
import {
|
|
32
33
|
TicketDetailDrawer,
|
|
33
34
|
type TicketDetailDrawerProps,
|
|
@@ -64,25 +65,35 @@ export function TicketRow({
|
|
|
64
65
|
// arrived yet, so action targets would be undefined.
|
|
65
66
|
const optimistic = isOptimistic(ticket)
|
|
66
67
|
|
|
67
|
-
// Scroll the
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// hidden — the user has to scroll manually to see what they just
|
|
71
|
-
// opened.
|
|
68
|
+
// Scroll the clicked card to the top of the viewport. Every click
|
|
69
|
+
// scrolls — first-click expansion, same-row re-click, cross-row
|
|
70
|
+
// switch. The clicked card lands at the top.
|
|
72
71
|
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
72
|
+
// Cross-row gotcha: if ANOTHER row above this one is currently
|
|
73
|
+
// expanded, its drawer is about to collapse simultaneously with our
|
|
74
|
+
// toggle. We pre-subtract its height from the target Y so the
|
|
75
|
+
// smooth-scroll lands at the FINAL post-collapse position cleanly.
|
|
76
|
+
// Same pattern as `<HelpCenterCard>` — the only diff is the drawer
|
|
77
|
+
// id prefix (`ticket-drawer-` vs `help-center-drawer-`).
|
|
77
78
|
const rowRef = useRef<HTMLDivElement | null>(null)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
rowRef.current
|
|
83
|
-
|
|
79
|
+
const handleClick = useCallback(() => {
|
|
80
|
+
onToggle(ticket.id)
|
|
81
|
+
scrollElementIntoView(rowRef.current, {
|
|
82
|
+
adjustTargetY: (raw) => {
|
|
83
|
+
if (!rowRef.current) return raw
|
|
84
|
+
const expandedDrawer = document.querySelector(
|
|
85
|
+
'div[id^="ticket-drawer-"]',
|
|
86
|
+
)
|
|
87
|
+
if (!(expandedDrawer instanceof HTMLElement)) return raw
|
|
88
|
+
const drawerRect = expandedDrawer.getBoundingClientRect()
|
|
89
|
+
const myRect = rowRef.current.getBoundingClientRect()
|
|
90
|
+
// Only adjust when the drawer is ABOVE us. Drawers below
|
|
91
|
+
// don't shift our position when they collapse.
|
|
92
|
+
if (drawerRect.bottom > myRect.top) return raw
|
|
93
|
+
return raw - drawerRect.height
|
|
94
|
+
},
|
|
84
95
|
})
|
|
85
|
-
}, [
|
|
96
|
+
}, [onToggle, ticket.id])
|
|
86
97
|
|
|
87
98
|
const tileData: ChatTicketItemData = {
|
|
88
99
|
id: ticket.id,
|
|
@@ -112,14 +123,14 @@ export function TicketRow({
|
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
return (
|
|
115
|
-
<div ref={rowRef} className="scroll-mt-
|
|
126
|
+
<div ref={rowRef} className="scroll-mt-24">
|
|
116
127
|
<Collapsible
|
|
117
128
|
open={expanded && !optimistic}
|
|
118
129
|
className="border-b border-ods-border last:border-b-0"
|
|
119
130
|
>
|
|
120
131
|
<ChatTicketItem
|
|
121
132
|
ticket={tileData}
|
|
122
|
-
onClick={optimistic ? undefined :
|
|
133
|
+
onClick={optimistic ? undefined : handleClick}
|
|
123
134
|
aria-expanded={expanded && !optimistic}
|
|
124
135
|
aria-controls={`ticket-drawer-${ticket.id}`}
|
|
125
136
|
/>
|
|
@@ -62,12 +62,15 @@ export interface ChatRuntime {
|
|
|
62
62
|
* relative `/api/storage/view/chat-attachments/` is sufficient
|
|
63
63
|
* (same-origin); embedders supply an absolute hub URL so the
|
|
64
64
|
* browser can fetch cross-origin.
|
|
65
|
-
* - `
|
|
65
|
+
* - `identityUrl` — GET endpoint the `useChatIdentity` hook
|
|
66
66
|
* hits to learn the `{authTier, source, attachmentsEnabled}`
|
|
67
|
-
* capability bag for the current session.
|
|
67
|
+
* capability bag for the current session. Used beyond chat
|
|
68
|
+
* (tickets / contact form / any embedded surface that needs
|
|
69
|
+
* to identify the proxied customer), so the name has no
|
|
70
|
+
* "chat" prefix even though the consuming hook still does. */
|
|
68
71
|
attachmentUploadUrl: string
|
|
69
72
|
attachmentViewUrlPrefix: string
|
|
70
|
-
|
|
73
|
+
identityUrl: string
|
|
71
74
|
/** Optional URL prefix for the image proxy (`<prefix>?url=<external>`).
|
|
72
75
|
* When unset, lib's `getProxiedImageUrl` returns the original URL
|
|
73
76
|
* unchanged. Hub default: '/api/image-proxy'. Embedders that don't
|
|
@@ -35,7 +35,7 @@ function createMockRuntime(): ChatRuntime {
|
|
|
35
35
|
buildListUrl: () => null,
|
|
36
36
|
attachmentUploadUrl: '/__story__/upload',
|
|
37
37
|
attachmentViewUrlPrefix: '/__story__/view/',
|
|
38
|
-
|
|
38
|
+
identityUrl: '/__story__/identity',
|
|
39
39
|
},
|
|
40
40
|
navigation: {
|
|
41
41
|
mode: 'embed',
|
package/src/utils/index.ts
CHANGED
|
@@ -200,3 +200,15 @@ export { fetchPriorityProp, type FetchPriorityValue } from './fetch-priority'
|
|
|
200
200
|
// server-safe (no JSX, no contexts/* imports); imported by route-page
|
|
201
201
|
// `metadata` exports + the shared `<DevSectionView>` chrome.
|
|
202
202
|
export * from './dev-sections'
|
|
203
|
+
|
|
204
|
+
// Canonical "smooth scroll element into view with sticky-chrome
|
|
205
|
+
// offset" helper. Single source of truth across the lib + hub for
|
|
206
|
+
// pre-computed-target scrolling — see `scroll-into-view.ts` for the
|
|
207
|
+
// rationale (TL;DR: window.scrollTo({top, behavior:'smooth'}) with a
|
|
208
|
+
// pre-computed pixel value avoids the mid-animation jitter that
|
|
209
|
+
// `element.scrollIntoView()` produces when layout shifts during the
|
|
210
|
+
// scroll).
|
|
211
|
+
export {
|
|
212
|
+
type ScrollElementIntoViewOptions,
|
|
213
|
+
scrollElementIntoView,
|
|
214
|
+
} from './scroll-into-view'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scrollElementIntoView` — canonical "smooth scroll element to top
|
|
3
|
+
* of viewport, account for sticky chrome, optionally adjust for
|
|
4
|
+
* known layout shifts" helper.
|
|
5
|
+
*
|
|
6
|
+
* Before this util existed, ~3 different call sites in the lib + hub
|
|
7
|
+
* had the same 5-line snippet copy-pasted with subtle differences:
|
|
8
|
+
*
|
|
9
|
+
* - `useUnifiedNav` same-URL re-scroll branch (hub)
|
|
10
|
+
* - `<HelpCenterCard>` click-to-expand (lib, with cross-row
|
|
11
|
+
* layout-shift adjustment)
|
|
12
|
+
* - Future ticket-row / docs anchor scrolls
|
|
13
|
+
*
|
|
14
|
+
* The canonical pattern is `window.scrollTo({top, behavior:'smooth'})`
|
|
15
|
+
* with a pre-computed pixel target — NOT `element.scrollIntoView()`.
|
|
16
|
+
* Pre-computing the target lets the browser run a clean uninterrupted
|
|
17
|
+
* smooth animation to a fixed pixel value. `scrollIntoView` re-targets
|
|
18
|
+
* continuously as the page layout shifts during the animation, which
|
|
19
|
+
* causes visible jitter when content above the target is also moving
|
|
20
|
+
* (a sibling collapsing, an async image loading, …).
|
|
21
|
+
*
|
|
22
|
+
* The `adjustTargetY` callback is the escape hatch for cases where
|
|
23
|
+
* the consumer KNOWS about an upcoming layout shift and can compute
|
|
24
|
+
* the correct FINAL target before the animation starts. Example: in
|
|
25
|
+
* `HelpCenterCard`, clicking row B while row A above is currently
|
|
26
|
+
* expanded — A's drawer collapses simultaneously with B's expansion,
|
|
27
|
+
* shifting B's tile up by A's drawer height. The consumer passes
|
|
28
|
+
* `adjustTargetY: raw => raw - getAboveDrawerHeight()` and the
|
|
29
|
+
* browser smooth-scrolls to the post-collapse position directly.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface ScrollElementIntoViewOptions {
|
|
33
|
+
/** Pixels to subtract from the target element's `top` so it lands
|
|
34
|
+
* BELOW sticky chrome. Defaults to 0. Pass `96` (matches
|
|
35
|
+
* `scroll-mt-24`) for the standard hub header offset. */
|
|
36
|
+
headerOffset?: number
|
|
37
|
+
/** Scroll animation style. Defaults to `'smooth'`. Use `'instant'`
|
|
38
|
+
* for imperative jumps where animation would feel laggy (deep
|
|
39
|
+
* link land, programmatic focus moves). */
|
|
40
|
+
behavior?: ScrollBehavior
|
|
41
|
+
/** Optional adjustment applied to the computed pixel target. The
|
|
42
|
+
* callback receives the "raw" Y (`element.top + scrollY -
|
|
43
|
+
* headerOffset`) and returns the FINAL pixel target. Use this
|
|
44
|
+
* when the caller knows about a layout shift that will happen
|
|
45
|
+
* between the call and the animation completing — the browser's
|
|
46
|
+
* smooth-scroll commits to a single pixel value, so providing the
|
|
47
|
+
* post-shift target up front lands the element correctly even as
|
|
48
|
+
* content above it moves. */
|
|
49
|
+
adjustTargetY?: (rawTargetY: number) => number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Scroll the page so `target` lands at the top of the viewport,
|
|
54
|
+
* accounting for sticky chrome via `headerOffset`. Returns void; the
|
|
55
|
+
* scroll runs async via the browser's smooth-scroll engine.
|
|
56
|
+
*
|
|
57
|
+
* Accepts:
|
|
58
|
+
* - `HTMLElement` — direct reference (most common from `useRef`).
|
|
59
|
+
* - `null` / `undefined` — no-op so callers can pass refs without
|
|
60
|
+
* defensive branching.
|
|
61
|
+
*
|
|
62
|
+
* SSR-safe: short-circuits when `window` is undefined.
|
|
63
|
+
*/
|
|
64
|
+
export function scrollElementIntoView(
|
|
65
|
+
target: HTMLElement | null | undefined,
|
|
66
|
+
options: ScrollElementIntoViewOptions = {},
|
|
67
|
+
): void {
|
|
68
|
+
if (typeof window === 'undefined' || !target) return
|
|
69
|
+
const { headerOffset = 0, behavior = 'smooth', adjustTargetY } = options
|
|
70
|
+
const rawTargetY =
|
|
71
|
+
target.getBoundingClientRect().top + window.scrollY - headerOffset
|
|
72
|
+
const finalY = adjustTargetY ? adjustTargetY(rawTargetY) : rawTargetY
|
|
73
|
+
window.scrollTo({ top: Math.max(0, finalY), behavior })
|
|
74
|
+
}
|