@flamingo-stack/openframe-frontend-core 0.0.691 → 0.0.692

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 (128) hide show
  1. package/dist/{chunk-LCENRN2N.cjs → chunk-5XPK2QGU.cjs} +3 -3
  2. package/dist/{chunk-LCENRN2N.cjs.map → chunk-5XPK2QGU.cjs.map} +1 -1
  3. package/dist/{chunk-SA4KQCNB.cjs → chunk-7BNWMOPM.cjs} +36 -36
  4. package/dist/{chunk-SA4KQCNB.cjs.map → chunk-7BNWMOPM.cjs.map} +1 -1
  5. package/dist/{chunk-6Z26ZMR4.js → chunk-A5NHAHV6.js} +2 -2
  6. package/dist/{chunk-Y3Q76RAA.cjs → chunk-ATREVNIN.cjs} +33 -33
  7. package/dist/{chunk-Y3Q76RAA.cjs.map → chunk-ATREVNIN.cjs.map} +1 -1
  8. package/dist/{chunk-FNMQ2J44.js → chunk-AVAZ6BD6.js} +2 -2
  9. package/dist/{chunk-DDWLF2JV.cjs → chunk-CG7OWW7X.cjs} +4 -4
  10. package/dist/{chunk-DDWLF2JV.cjs.map → chunk-CG7OWW7X.cjs.map} +1 -1
  11. package/dist/{chunk-KMW2PXBU.js → chunk-CKO6NKNE.js} +750 -212
  12. package/dist/chunk-CKO6NKNE.js.map +1 -0
  13. package/dist/{chunk-ZLYLHP64.cjs → chunk-CKUPANVU.cjs} +4 -4
  14. package/dist/{chunk-ZLYLHP64.cjs.map → chunk-CKUPANVU.cjs.map} +1 -1
  15. package/dist/{chunk-H54RDLVC.cjs → chunk-DKJDO7YM.cjs} +18 -18
  16. package/dist/{chunk-H54RDLVC.cjs.map → chunk-DKJDO7YM.cjs.map} +1 -1
  17. package/dist/{chunk-N4S7RBUM.cjs → chunk-DUURFYGS.cjs} +3 -3
  18. package/dist/{chunk-N4S7RBUM.cjs.map → chunk-DUURFYGS.cjs.map} +1 -1
  19. package/dist/{chunk-CWXRZVDI.cjs → chunk-EXWS2QSD.cjs} +588 -50
  20. package/dist/chunk-EXWS2QSD.cjs.map +1 -0
  21. package/dist/{chunk-WZYIAKIH.cjs → chunk-F6LXGP6S.cjs} +3 -3
  22. package/dist/{chunk-WZYIAKIH.cjs.map → chunk-F6LXGP6S.cjs.map} +1 -1
  23. package/dist/{chunk-4N2JOPAQ.cjs → chunk-FTK3S57R.cjs} +13 -13
  24. package/dist/{chunk-4N2JOPAQ.cjs.map → chunk-FTK3S57R.cjs.map} +1 -1
  25. package/dist/{chunk-MJI5GVT7.js → chunk-IQTQIBZ3.js} +4 -4
  26. package/dist/{chunk-BXE2RZEV.cjs → chunk-KDCIYRZV.cjs} +17 -17
  27. package/dist/{chunk-BXE2RZEV.cjs.map → chunk-KDCIYRZV.cjs.map} +1 -1
  28. package/dist/{chunk-ESFPY5FF.cjs → chunk-KQZMM3QJ.cjs} +51 -51
  29. package/dist/{chunk-ESFPY5FF.cjs.map → chunk-KQZMM3QJ.cjs.map} +1 -1
  30. package/dist/{chunk-Y7ZQZOE6.js → chunk-LYJJH4D6.js} +2 -2
  31. package/dist/{chunk-TSNGVWQO.js → chunk-LYQULSK4.js} +5 -5
  32. package/dist/{chunk-KVDLL4LU.js → chunk-MCNQKS6I.js} +2 -2
  33. package/dist/{chunk-DC3G4XGP.js → chunk-PKZGXRU2.js} +3 -3
  34. package/dist/{chunk-Y3BC7TOJ.cjs → chunk-Q25VYAEL.cjs} +17 -17
  35. package/dist/{chunk-Y3BC7TOJ.cjs.map → chunk-Q25VYAEL.cjs.map} +1 -1
  36. package/dist/{chunk-JSG7WZKV.js → chunk-RK47E3PP.js} +2 -2
  37. package/dist/{chunk-4HZXOQRM.js → chunk-RLNNV46K.js} +2 -2
  38. package/dist/{chunk-BXAGR3PO.cjs → chunk-RTKF5AYA.cjs} +3 -3
  39. package/dist/{chunk-BXAGR3PO.cjs.map → chunk-RTKF5AYA.cjs.map} +1 -1
  40. package/dist/{chunk-Z5FHXCXF.js → chunk-TEIHEFXP.js} +4 -4
  41. package/dist/{chunk-XBYF4SGD.js → chunk-TXFGG47A.js} +2 -2
  42. package/dist/{chunk-5N2ZSB42.cjs → chunk-WTYQCT4A.cjs} +10 -10
  43. package/dist/{chunk-5N2ZSB42.cjs.map → chunk-WTYQCT4A.cjs.map} +1 -1
  44. package/dist/{chunk-CMYDGVTZ.js → chunk-X5XFKI6N.js} +2 -2
  45. package/dist/{chunk-7WYWPELF.js → chunk-Z7QCNCBN.js} +5 -5
  46. package/dist/{chunk-DZMDG7PC.js → chunk-ZAV2F4VX.js} +2 -2
  47. package/dist/components/case-studies/index.cjs +11 -11
  48. package/dist/components/case-studies/index.js +2 -2
  49. package/dist/components/contact/index.cjs +3 -3
  50. package/dist/components/contact/index.js +2 -2
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +3 -3
  54. package/dist/components/embeds/index.js +2 -2
  55. package/dist/components/faq/index.cjs +3 -3
  56. package/dist/components/faq/index.js +2 -2
  57. package/dist/components/features/index.cjs +34 -2
  58. package/dist/components/features/index.cjs.map +1 -1
  59. package/dist/components/features/index.d.ts +1 -0
  60. package/dist/components/features/index.d.ts.map +1 -1
  61. package/dist/components/features/index.js +33 -1
  62. package/dist/components/features/remote-session/index.d.ts +13 -0
  63. package/dist/components/features/remote-session/index.d.ts.map +1 -0
  64. package/dist/components/features/remote-session/remote-access-request-card.d.ts +19 -0
  65. package/dist/components/features/remote-session/remote-access-request-card.d.ts.map +1 -0
  66. package/dist/components/features/remote-session/remote-session-block.d.ts +41 -0
  67. package/dist/components/features/remote-session/remote-session-block.d.ts.map +1 -0
  68. package/dist/components/features/remote-session/remote-session-chat-panel.d.ts +23 -0
  69. package/dist/components/features/remote-session/remote-session-chat-panel.d.ts.map +1 -0
  70. package/dist/components/features/remote-session/remote-session-dialog.d.ts +15 -0
  71. package/dist/components/features/remote-session/remote-session-dialog.d.ts.map +1 -0
  72. package/dist/components/features/remote-session/remote-session-overlay.d.ts +12 -0
  73. package/dist/components/features/remote-session/remote-session-overlay.d.ts.map +1 -0
  74. package/dist/components/features/remote-session/remote-session-parts.d.ts +34 -0
  75. package/dist/components/features/remote-session/remote-session-parts.d.ts.map +1 -0
  76. package/dist/components/features/remote-session/types.d.ts +33 -0
  77. package/dist/components/features/remote-session/types.d.ts.map +1 -0
  78. package/dist/components/help-center-pages/index.cjs +49 -49
  79. package/dist/components/help-center-pages/index.js +13 -13
  80. package/dist/components/index.cjs +64 -32
  81. package/dist/components/index.cjs.map +1 -1
  82. package/dist/components/index.js +44 -12
  83. package/dist/components/index.js.map +1 -1
  84. package/dist/components/meeting-scheduler/index.cjs +22 -22
  85. package/dist/components/meeting-scheduler/index.js +1 -1
  86. package/dist/components/navigation/index.cjs +5 -5
  87. package/dist/components/navigation/index.js +4 -4
  88. package/dist/components/onboarding-guides/index.cjs +5 -5
  89. package/dist/components/onboarding-guides/index.js +4 -4
  90. package/dist/components/tickets/index.cjs +6 -6
  91. package/dist/components/tickets/index.js +5 -5
  92. package/dist/components/ui/index.cjs +2 -2
  93. package/dist/components/ui/index.js +1 -1
  94. package/dist/index.cjs +38 -6
  95. package/dist/index.cjs.map +1 -1
  96. package/dist/index.js +37 -5
  97. package/dist/index.js.map +1 -1
  98. package/package.json +1 -1
  99. package/src/components/features/__tests__/remote-session.test.tsx +97 -0
  100. package/src/components/features/index.ts +1 -0
  101. package/src/components/features/remote-session/index.ts +36 -0
  102. package/src/components/features/remote-session/remote-access-request-card.tsx +102 -0
  103. package/src/components/features/remote-session/remote-session-block.tsx +182 -0
  104. package/src/components/features/remote-session/remote-session-chat-panel.tsx +178 -0
  105. package/src/components/features/remote-session/remote-session-dialog.tsx +77 -0
  106. package/src/components/features/remote-session/remote-session-overlay.tsx +33 -0
  107. package/src/components/features/remote-session/remote-session-parts.tsx +138 -0
  108. package/src/components/features/remote-session/types.ts +41 -0
  109. package/src/stories/RemoteSessionAccessRequest.stories.tsx +71 -0
  110. package/src/stories/RemoteSessionBlock.stories.tsx +164 -0
  111. package/src/stories/RemoteSessionDialog.stories.tsx +49 -0
  112. package/src/stories/RemoteSessionIndicator.stories.tsx +44 -0
  113. package/dist/chunk-CWXRZVDI.cjs.map +0 -1
  114. package/dist/chunk-KMW2PXBU.js.map +0 -1
  115. /package/dist/{chunk-6Z26ZMR4.js.map → chunk-A5NHAHV6.js.map} +0 -0
  116. /package/dist/{chunk-FNMQ2J44.js.map → chunk-AVAZ6BD6.js.map} +0 -0
  117. /package/dist/{chunk-MJI5GVT7.js.map → chunk-IQTQIBZ3.js.map} +0 -0
  118. /package/dist/{chunk-Y7ZQZOE6.js.map → chunk-LYJJH4D6.js.map} +0 -0
  119. /package/dist/{chunk-TSNGVWQO.js.map → chunk-LYQULSK4.js.map} +0 -0
  120. /package/dist/{chunk-KVDLL4LU.js.map → chunk-MCNQKS6I.js.map} +0 -0
  121. /package/dist/{chunk-DC3G4XGP.js.map → chunk-PKZGXRU2.js.map} +0 -0
  122. /package/dist/{chunk-JSG7WZKV.js.map → chunk-RK47E3PP.js.map} +0 -0
  123. /package/dist/{chunk-4HZXOQRM.js.map → chunk-RLNNV46K.js.map} +0 -0
  124. /package/dist/{chunk-Z5FHXCXF.js.map → chunk-TEIHEFXP.js.map} +0 -0
  125. /package/dist/{chunk-XBYF4SGD.js.map → chunk-TXFGG47A.js.map} +0 -0
  126. /package/dist/{chunk-CMYDGVTZ.js.map → chunk-X5XFKI6N.js.map} +0 -0
  127. /package/dist/{chunk-7WYWPELF.js.map → chunk-Z7QCNCBN.js.map} +0 -0
  128. /package/dist/{chunk-DZMDG7PC.js.map → chunk-ZAV2F4VX.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.691",
3
+ "version": "0.0.692",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Remote session widgets: the elapsed format, the consent card's https-only
3
+ * site link and its decision lock, and the chat composer's send.
4
+ */
5
+
6
+ import { fireEvent, render, screen } from '@testing-library/react';
7
+ import { describe, expect, it, vi } from 'vitest';
8
+ import {
9
+ formatRemoteSessionElapsed,
10
+ RemoteAccessRequestCard,
11
+ RemoteSessionChatPanel,
12
+ RemoteSessionMinimizedChip,
13
+ } from '../remote-session';
14
+
15
+ const party = { organizationName: 'TechFlow Solutions', organizationSiteUrl: 'https://www.techflow.com' };
16
+
17
+ describe('formatRemoteSessionElapsed', () => {
18
+ it('counts minutes and seconds, then hours', () => {
19
+ expect(formatRemoteSessionElapsed(0)).toBe('0:00');
20
+ expect(formatRemoteSessionElapsed(323_000)).toBe('5:23');
21
+ expect(formatRemoteSessionElapsed(3_723_000)).toBe('1:02:03');
22
+ });
23
+
24
+ it('never goes negative when the clock is behind the server', () => {
25
+ expect(formatRemoteSessionElapsed(-5_000)).toBe('0:00');
26
+ });
27
+ });
28
+
29
+ describe('RemoteAccessRequestCard', () => {
30
+ it('offers the site only when it is https', () => {
31
+ const onOpenSite = vi.fn();
32
+ const { rerender } = render(<RemoteAccessRequestCard party={party} onDecide={() => {}} onOpenSite={onOpenSite} />);
33
+ fireEvent.click(screen.getByRole('button', { name: 'Open website' }));
34
+ expect(onOpenSite).toHaveBeenCalledWith('https://www.techflow.com');
35
+
36
+ rerender(
37
+ <RemoteAccessRequestCard
38
+ party={{ ...party, organizationSiteUrl: 'http://techflow.local' }}
39
+ onDecide={() => {}}
40
+ onOpenSite={onOpenSite}
41
+ />,
42
+ );
43
+ expect(screen.getByText('techflow.local')).toBeInTheDocument();
44
+ expect(screen.queryByRole('button', { name: 'Open website' })).toBeNull();
45
+ });
46
+
47
+ it('reports the decision, and locks both buttons while one is in flight', () => {
48
+ const onDecide = vi.fn();
49
+ const { rerender } = render(<RemoteAccessRequestCard party={party} onDecide={onDecide} />);
50
+ fireEvent.click(screen.getByRole('button', { name: 'Allow Access' }));
51
+ expect(onDecide).toHaveBeenCalledWith('APPROVED');
52
+
53
+ rerender(<RemoteAccessRequestCard party={party} onDecide={onDecide} deciding="APPROVED" />);
54
+ expect(screen.getByRole('button', { name: 'Decline' })).toBeDisabled();
55
+ });
56
+
57
+ it('shows the recording notice only when asked to', () => {
58
+ const { rerender } = render(<RemoteAccessRequestCard party={party} onDecide={() => {}} />);
59
+ expect(screen.queryByText(/will be recorded/)).toBeNull();
60
+ rerender(<RemoteAccessRequestCard party={party} onDecide={() => {}} showRecordingNotice />);
61
+ expect(screen.getByText(/will be recorded/)).toBeInTheDocument();
62
+ });
63
+ });
64
+
65
+ describe('RemoteSessionChatPanel', () => {
66
+ it('sends the trimmed draft and clears it; a blank draft sends nothing', () => {
67
+ const onSend = vi.fn();
68
+ const onDraftChange = vi.fn();
69
+ const props = {
70
+ party,
71
+ elapsed: '1:00',
72
+ messages: [],
73
+ onDraftChange,
74
+ onSend,
75
+ onCloseChat: () => {},
76
+ onEndSession: () => {},
77
+ };
78
+ const { rerender } = render(<RemoteSessionChatPanel {...props} draft=" hello " />);
79
+ fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
80
+ expect(onSend).toHaveBeenCalledWith('hello');
81
+ expect(onDraftChange).toHaveBeenCalledWith('');
82
+
83
+ onSend.mockClear();
84
+ rerender(<RemoteSessionChatPanel {...props} draft=" " />);
85
+ fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
86
+ expect(onSend).not.toHaveBeenCalled();
87
+ });
88
+ });
89
+
90
+ describe('RemoteSessionMinimizedChip', () => {
91
+ it('expands on click', () => {
92
+ const onExpand = vi.fn();
93
+ render(<RemoteSessionMinimizedChip party={{ ...party, technicianName: 'Mike Rodriguez' }} onExpand={onExpand} />);
94
+ fireEvent.click(screen.getByRole('button', { name: 'Show session block' }));
95
+ expect(onExpand).toHaveBeenCalledTimes(1);
96
+ });
97
+ });
@@ -92,6 +92,7 @@ export * from './policy-configuration-panel';
92
92
  export * from './waitlist-form';
93
93
  export * from './notifications';
94
94
  export * from './time-tracker';
95
+ export * from './remote-session';
95
96
  export * from './board';
96
97
  export * from './sortable-list';
97
98
  export * from './ticket-status-config-list';
@@ -0,0 +1,36 @@
1
+ export { RemoteAccessRequestCard } from './remote-access-request-card';
2
+ export type { RemoteAccessRequestCardProps } from './remote-access-request-card';
3
+ export {
4
+ RemoteSessionBlockFrame,
5
+ RemoteSessionEndButton,
6
+ RemoteSessionMinimizedChip,
7
+ RemoteSessionSummary,
8
+ } from './remote-session-block';
9
+ export type {
10
+ RemoteSessionBlockFrameProps,
11
+ RemoteSessionMinimizedChipProps,
12
+ RemoteSessionSummaryProps,
13
+ } from './remote-session-block';
14
+ export { RemoteSessionChatPanel } from './remote-session-chat-panel';
15
+ export type { RemoteSessionChatPanelProps } from './remote-session-chat-panel';
16
+ export { RemoteSessionDialog } from './remote-session-dialog';
17
+ export type { RemoteSessionDialogProps } from './remote-session-dialog';
18
+ export { RemoteSessionBorder, RemoteSessionPill } from './remote-session-overlay';
19
+ export {
20
+ REMOTE_SESSION_DRAG_HANDLE_CLASS,
21
+ RemoteSessionDragger,
22
+ RemoteSessionOrgLogo,
23
+ RemoteSessionRecordingTag,
24
+ RemoteSessionScope,
25
+ formatRemoteSessionElapsed,
26
+ useRemoteSessionTimer,
27
+ } from './remote-session-parts';
28
+ export type { RemoteSessionOrgLogoProps, RemoteSessionScopeProps } from './remote-session-parts';
29
+ export type {
30
+ RemoteAccessDecision,
31
+ RemoteSessionChatAuthor,
32
+ RemoteSessionChatMessage,
33
+ RemoteSessionDialogVariant,
34
+ RemoteSessionDragHandlers,
35
+ RemoteSessionParty,
36
+ } from './types';
@@ -0,0 +1,102 @@
1
+ 'use client';
2
+
3
+ import { ExternalLinkIcon, InfoCircleIcon } from '../../icons-v2-generated';
4
+ import { Button } from '../../ui/button/button';
5
+ import { RemoteSessionOrgLogo } from './remote-session-parts';
6
+ import type { RemoteAccessDecision, RemoteSessionParty } from './types';
7
+
8
+ export interface RemoteAccessRequestCardProps {
9
+ party: RemoteSessionParty;
10
+ /** The notice that the session will be recorded. */
11
+ showRecordingNotice?: boolean;
12
+ /** The decision in flight, if any; both buttons lock while it is set. */
13
+ deciding?: RemoteAccessDecision | null;
14
+ /** Replaces the "you can end the session" line, e.g. when the decision could not be sent. */
15
+ error?: string | null;
16
+ onDecide: (decision: RemoteAccessDecision) => void;
17
+ /**
18
+ * Opens the organization's site. The button only shows for an https URL:
19
+ * the URL comes from the server, so no other scheme is ever handed out.
20
+ */
21
+ onOpenSite?: (url: string) => void;
22
+ }
23
+
24
+ /** `https://www.techflow.com/` -> `www.techflow.com`. */
25
+ function displaySiteUrl(url: string): string {
26
+ return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
27
+ }
28
+
29
+ /** The end user's consent dialog for a remote access request: who is asking, what they get, Decline / Allow Access. */
30
+ export function RemoteAccessRequestCard({
31
+ party,
32
+ showRecordingNotice = false,
33
+ deciding = null,
34
+ error,
35
+ onDecide,
36
+ onOpenSite,
37
+ }: RemoteAccessRequestCardProps) {
38
+ const { organizationName } = party;
39
+ const siteUrl = party.organizationSiteUrl ?? undefined;
40
+ const openableSite = siteUrl?.startsWith('https://') ? siteUrl : undefined;
41
+
42
+ return (
43
+ <div className="flex w-full flex-col gap-[var(--spacing-system-l)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-xl)]">
44
+ <h1 className="w-full text-ods-text-primary text-h2">Your IT support team is requesting remote access</h1>
45
+
46
+ <div className="flex w-full items-center gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-bg p-[var(--spacing-system-m)]">
47
+ <RemoteSessionOrgLogo name={organizationName} logoUrl={party.organizationLogoUrl} sizePx={64} />
48
+ <div className="flex min-w-0 flex-1 flex-col">
49
+ <p className="w-full truncate text-ods-text-primary text-h3">{organizationName}</p>
50
+ {siteUrl && <p className="w-full truncate text-ods-text-secondary text-h4">{displaySiteUrl(siteUrl)}</p>}
51
+ </div>
52
+ {openableSite && onOpenSite && (
53
+ <Button variant="outline" size="icon" aria-label="Open website" onClick={() => onOpenSite(openableSite)}>
54
+ <ExternalLinkIcon size={24} color="currentColor" />
55
+ </Button>
56
+ )}
57
+ </div>
58
+
59
+ <p className="w-full text-ods-text-primary text-h4">
60
+ A technician from <span className="text-ods-accent">{organizationName}</span> will be able to view your screen
61
+ and control your mouse and keyboard during this session.
62
+ </p>
63
+
64
+ <div className="flex w-full flex-col gap-[var(--spacing-system-xs)]">
65
+ {showRecordingNotice && (
66
+ <div className="flex w-full items-center gap-[var(--spacing-system-xs)] rounded-md border border-ods-border bg-ods-bg p-[var(--spacing-system-s)]">
67
+ <InfoCircleIcon size={24} color="currentColor" className="shrink-0 text-ods-accent" />
68
+ <p className="min-w-0 flex-1 text-ods-text-primary text-h6">
69
+ This session will be recorded and available upon request.
70
+ </p>
71
+ </div>
72
+ )}
73
+ {error ? (
74
+ <p className="w-full text-ods-text-primary text-h6">{error}</p>
75
+ ) : (
76
+ <p className="w-full text-ods-text-secondary text-h6">You can end the session at any time.</p>
77
+ )}
78
+ </div>
79
+
80
+ <div className="flex w-full items-stretch gap-[var(--spacing-system-l)]">
81
+ <Button
82
+ variant="outline"
83
+ fullWidth
84
+ loading={deciding === 'DENIED'}
85
+ disabled={deciding !== null}
86
+ onClick={() => onDecide('DENIED')}
87
+ >
88
+ Decline
89
+ </Button>
90
+ <Button
91
+ variant="accent"
92
+ fullWidth
93
+ loading={deciding === 'APPROVED'}
94
+ disabled={deciding !== null}
95
+ onClick={() => onDecide('APPROVED')}
96
+ >
97
+ Allow Access
98
+ </Button>
99
+ </div>
100
+ </div>
101
+ );
102
+ }
@@ -0,0 +1,182 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, type ReactNode } from 'react';
4
+ import { cn } from '../../../utils/cn';
5
+ import { ChatsIcon, Chevrons03LeftIcon, Chevrons03RightIcon, MonitorOffIcon } from '../../icons-v2-generated';
6
+ import { OpenFrameLogo } from '../../icons/openframe-logo';
7
+ import { Button } from '../../ui/button/button';
8
+ import { SquareAvatar } from '../../ui/square-avatar';
9
+ import {
10
+ REMOTE_SESSION_DRAG_HANDLE_CLASS,
11
+ RemoteSessionDragger,
12
+ RemoteSessionOrgLogo,
13
+ RemoteSessionRecordingTag,
14
+ } from './remote-session-parts';
15
+ import type { RemoteSessionDragHandlers, RemoteSessionParty } from './types';
16
+
17
+ export interface RemoteSessionBlockFrameProps {
18
+ /** The summary or the chat panel. */
19
+ children: ReactNode;
20
+ /** The chat panel has a fixed height; the summary hugs its content. */
21
+ expanded?: boolean;
22
+ onHide: () => void;
23
+ className?: string;
24
+ }
25
+
26
+ /**
27
+ * The session block's card: 440 px wide, the open panel on top and the
28
+ * "Hide this Block" bar at the bottom. The ref is the rendered surface, which
29
+ * a desktop host sizes its window to.
30
+ */
31
+ export const RemoteSessionBlockFrame = forwardRef<HTMLDivElement, RemoteSessionBlockFrameProps>(
32
+ ({ children, expanded = false, onHide, className }, ref) => {
33
+ return (
34
+ <div
35
+ ref={ref}
36
+ className={cn(
37
+ 'flex w-[440px] shrink-0 flex-col overflow-hidden rounded-md border border-ods-border bg-ods-bg',
38
+ expanded && 'h-[680px]',
39
+ className,
40
+ )}
41
+ >
42
+ {children}
43
+ <button
44
+ type="button"
45
+ className="flex h-12 w-full shrink-0 items-center gap-[var(--spacing-system-xs)] border-t border-ods-border bg-ods-card px-[var(--spacing-system-m)]"
46
+ onClick={onHide}
47
+ >
48
+ <Chevrons03RightIcon size={24} />
49
+ <span className="min-w-0 flex-1 truncate text-left text-ods-text-secondary text-h4">Hide this Block</span>
50
+ <OpenFrameLogo className="size-6 shrink-0" />
51
+ </button>
52
+ </div>
53
+ );
54
+ },
55
+ );
56
+ RemoteSessionBlockFrame.displayName = 'RemoteSessionBlockFrame';
57
+
58
+ /** The red End Session action shared by the summary and the chat panel. */
59
+ export function RemoteSessionEndButton({ onEndSession }: { onEndSession: () => void }) {
60
+ return (
61
+ <Button
62
+ variant="outline"
63
+ fullWidth
64
+ leftIcon={<MonitorOffIcon size={24} color="currentColor" className="text-ods-error" />}
65
+ onClick={onEndSession}
66
+ >
67
+ End Session
68
+ </Button>
69
+ );
70
+ }
71
+
72
+ export interface RemoteSessionSummaryProps {
73
+ party: RemoteSessionParty;
74
+ /** Elapsed session time, e.g. from `useRemoteSessionTimer`. */
75
+ elapsed: string;
76
+ showRecordingTag?: boolean;
77
+ onOpenChat: () => void;
78
+ onEndSession: () => void;
79
+ /** Makes the header row and the dragger move the window. */
80
+ dragHandlers?: RemoteSessionDragHandlers;
81
+ }
82
+
83
+ /** The compact block: who is connected and for how long, Open Chat / End Session. */
84
+ export function RemoteSessionSummary({
85
+ party,
86
+ elapsed,
87
+ showRecordingTag = false,
88
+ onOpenChat,
89
+ onEndSession,
90
+ dragHandlers,
91
+ }: RemoteSessionSummaryProps) {
92
+ const technician = party.technicianName;
93
+ return (
94
+ <div className="relative flex flex-col gap-[var(--spacing-system-m)] p-[var(--spacing-system-m)]">
95
+ <RemoteSessionDragger dragHandlers={dragHandlers} />
96
+ <div
97
+ className={cn(
98
+ 'flex w-full items-center gap-[var(--spacing-system-xs)] pr-[var(--spacing-system-mf)]',
99
+ dragHandlers && REMOTE_SESSION_DRAG_HANDLE_CLASS,
100
+ )}
101
+ {...dragHandlers}
102
+ >
103
+ {technician ? (
104
+ <SquareAvatar
105
+ variant="round"
106
+ sizePx={48}
107
+ src={party.technicianAvatarUrl ?? undefined}
108
+ fallback={technician}
109
+ />
110
+ ) : (
111
+ <RemoteSessionOrgLogo name={party.organizationName} logoUrl={party.organizationLogoUrl} sizePx={48} />
112
+ )}
113
+ <div className="flex min-w-0 flex-1 flex-col">
114
+ <p className="w-full truncate text-ods-text-primary text-h4">
115
+ {technician ? `${technician} connected` : 'IT support team is connected'}{' '}
116
+ <span className="text-ods-text-secondary">({elapsed})</span>
117
+ </p>
118
+ <p className="w-full truncate text-ods-text-secondary text-h4">{party.organizationName}</p>
119
+ </div>
120
+ </div>
121
+ {showRecordingTag && <RemoteSessionRecordingTag />}
122
+ <div className="flex w-full items-stretch gap-[var(--spacing-system-m)]">
123
+ <Button
124
+ variant="outline"
125
+ fullWidth
126
+ leftIcon={<ChatsIcon size={24} color="currentColor" />}
127
+ onClick={onOpenChat}
128
+ >
129
+ Open Chat
130
+ </Button>
131
+ <RemoteSessionEndButton onEndSession={onEndSession} />
132
+ </div>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ export interface RemoteSessionMinimizedChipProps {
138
+ party: RemoteSessionParty;
139
+ onExpand: () => void;
140
+ /** Lets the chip move the window too; the host tells a drag from a click. */
141
+ dragHandlers?: RemoteSessionDragHandlers;
142
+ }
143
+
144
+ /** The hidden block: the technician's avatar (or the organization's logo) and a chevron to bring it back. */
145
+ export const RemoteSessionMinimizedChip = forwardRef<HTMLButtonElement, RemoteSessionMinimizedChipProps>(
146
+ ({ party, onExpand, dragHandlers }, ref) => {
147
+ const technician = party.technicianName;
148
+ return (
149
+ <button
150
+ ref={ref}
151
+ type="button"
152
+ aria-label="Show session block"
153
+ className={cn(
154
+ 'flex shrink-0 flex-col overflow-hidden rounded-md border border-ods-border',
155
+ dragHandlers && REMOTE_SESSION_DRAG_HANDLE_CLASS,
156
+ )}
157
+ onClick={onExpand}
158
+ {...dragHandlers}
159
+ >
160
+ {technician ? (
161
+ <SquareAvatar
162
+ sizePx={48}
163
+ src={party.technicianAvatarUrl ?? undefined}
164
+ fallback={technician}
165
+ className="rounded-none"
166
+ />
167
+ ) : (
168
+ <RemoteSessionOrgLogo
169
+ name={party.organizationName}
170
+ logoUrl={party.organizationLogoUrl}
171
+ sizePx={48}
172
+ className="rounded-none border-0"
173
+ />
174
+ )}
175
+ <span className="flex h-9 w-12 items-center justify-center border-t border-ods-border bg-ods-card">
176
+ <Chevrons03LeftIcon size={24} />
177
+ </span>
178
+ </button>
179
+ );
180
+ },
181
+ );
182
+ RemoteSessionMinimizedChip.displayName = 'RemoteSessionMinimizedChip';
@@ -0,0 +1,178 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { cn } from '../../../utils/cn';
5
+ import { MessageOffIcon, Send03Icon } from '../../icons-v2-generated';
6
+ import { Button } from '../../ui/button/button';
7
+ import { SquareAvatar } from '../../ui/square-avatar';
8
+ import { RemoteSessionEndButton } from './remote-session-block';
9
+ import {
10
+ REMOTE_SESSION_DRAG_HANDLE_CLASS,
11
+ RemoteSessionDragger,
12
+ RemoteSessionRecordingTag,
13
+ } from './remote-session-parts';
14
+ import type { RemoteSessionChatMessage, RemoteSessionDragHandlers, RemoteSessionParty } from './types';
15
+
16
+ export interface RemoteSessionChatPanelProps {
17
+ party: RemoteSessionParty;
18
+ /** Elapsed session time, e.g. from `useRemoteSessionTimer`. */
19
+ elapsed: string;
20
+ messages: RemoteSessionChatMessage[];
21
+ showRecordingTag?: boolean;
22
+ /** The composer's text; owned by the host so it survives hiding the block. */
23
+ draft: string;
24
+ onDraftChange: (draft: string) => void;
25
+ /** Called with the trimmed text; the composer clears itself through `onDraftChange`. */
26
+ onSend: (text: string) => void;
27
+ /** False while the chat is still connecting: the composer is disabled. */
28
+ ready?: boolean;
29
+ error?: string | null;
30
+ onCloseChat: () => void;
31
+ onEndSession: () => void;
32
+ /** Makes the header and the dragger move the window. */
33
+ dragHandlers?: RemoteSessionDragHandlers;
34
+ }
35
+
36
+ function formatTime(at: Date): string {
37
+ return at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
38
+ }
39
+
40
+ /** The chat with the technician inside the session block. */
41
+ export function RemoteSessionChatPanel({
42
+ party,
43
+ elapsed,
44
+ messages,
45
+ showRecordingTag = false,
46
+ draft,
47
+ onDraftChange,
48
+ onSend,
49
+ ready = true,
50
+ error,
51
+ onCloseChat,
52
+ onEndSession,
53
+ dragHandlers,
54
+ }: RemoteSessionChatPanelProps) {
55
+ const listRef = useRef<HTMLDivElement>(null);
56
+
57
+ // The list is the trigger: the newest row must come into view on every change.
58
+ useEffect(() => {
59
+ const list = listRef.current;
60
+ if (list) list.scrollTop = list.scrollHeight;
61
+ }, [messages]);
62
+
63
+ const handleSend = () => {
64
+ const text = draft.trim();
65
+ if (!text) return;
66
+ onSend(text);
67
+ onDraftChange('');
68
+ };
69
+
70
+ return (
71
+ <div className="flex min-h-0 flex-1 flex-col">
72
+ <div className="relative flex flex-col gap-[var(--spacing-system-m)] p-[var(--spacing-system-m)]">
73
+ <RemoteSessionDragger dragHandlers={dragHandlers} />
74
+ <div
75
+ className={cn(
76
+ 'flex w-full flex-col pr-[var(--spacing-system-mf)]',
77
+ dragHandlers && REMOTE_SESSION_DRAG_HANDLE_CLASS,
78
+ )}
79
+ {...dragHandlers}
80
+ >
81
+ <p className="w-full truncate text-ods-text-primary text-h4">
82
+ Your IT support team is connected <span className="text-ods-text-secondary">({elapsed})</span>
83
+ </p>
84
+ <p className="w-full truncate text-ods-text-secondary text-h4">You can end this session at any time</p>
85
+ </div>
86
+ {showRecordingTag && <RemoteSessionRecordingTag />}
87
+ <div className="flex w-full items-stretch gap-[var(--spacing-system-m)]">
88
+ <Button
89
+ variant="outline"
90
+ fullWidth
91
+ leftIcon={<MessageOffIcon size={24} color="currentColor" />}
92
+ onClick={onCloseChat}
93
+ >
94
+ Close Chat
95
+ </Button>
96
+ <RemoteSessionEndButton onEndSession={onEndSession} />
97
+ </div>
98
+ </div>
99
+ <div
100
+ ref={listRef}
101
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-ods-card p-[var(--spacing-system-m)]"
102
+ >
103
+ {/* mt-auto (not justify-end) keeps the top of an overflowing list scrollable. */}
104
+ <div className="mt-auto flex flex-col gap-[var(--spacing-system-s)]">
105
+ {messages.map(message =>
106
+ message.author === 'system' ? (
107
+ <p key={message.id} className="w-full text-center text-ods-text-secondary text-h6">
108
+ {message.text}
109
+ </p>
110
+ ) : (
111
+ <RemoteSessionChatMessageRow key={message.id} message={message} party={party} />
112
+ ),
113
+ )}
114
+ </div>
115
+ </div>
116
+ {error && <p className="bg-ods-card px-[var(--spacing-system-m)] text-ods-error text-h6">{error}</p>}
117
+ <form
118
+ className="flex items-center gap-[var(--spacing-system-xs)] bg-ods-card px-[var(--spacing-system-m)] pb-[var(--spacing-system-m)]"
119
+ onSubmit={event => {
120
+ event.preventDefault();
121
+ handleSend();
122
+ }}
123
+ >
124
+ <input
125
+ value={draft}
126
+ onChange={event => onDraftChange(event.target.value)}
127
+ disabled={!ready}
128
+ placeholder={ready ? 'Enter your Message...' : 'Loading chat...'}
129
+ aria-label="Message"
130
+ className="h-12 min-w-0 flex-1 rounded-md border border-ods-border bg-ods-bg px-[var(--spacing-system-s)] text-ods-text-primary outline-none text-h6 placeholder:text-ods-text-secondary"
131
+ />
132
+ <button
133
+ type="submit"
134
+ aria-label="Send message"
135
+ disabled={!ready}
136
+ className="flex size-12 shrink-0 items-center justify-center"
137
+ >
138
+ <Send03Icon size={24} color="currentColor" className="text-ods-text-primary" />
139
+ </button>
140
+ </form>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ function RemoteSessionChatMessageRow({
146
+ message,
147
+ party,
148
+ }: {
149
+ message: RemoteSessionChatMessage;
150
+ party: RemoteSessionParty;
151
+ }) {
152
+ const isTechnician = message.author === 'technician';
153
+ const technician = message.name ?? party.technicianName ?? 'Technician';
154
+ return (
155
+ <div className="flex w-full flex-col">
156
+ <div className="flex w-full items-center gap-[var(--spacing-system-xs)]">
157
+ {isTechnician && (
158
+ <SquareAvatar
159
+ variant="round"
160
+ sizePx={16}
161
+ src={party.technicianAvatarUrl ?? undefined}
162
+ fallback={technician}
163
+ />
164
+ )}
165
+ <span
166
+ className={cn(
167
+ 'min-w-0 flex-1 truncate text-code',
168
+ isTechnician ? 'text-ods-accent' : 'text-ods-text-secondary',
169
+ )}
170
+ >
171
+ {isTechnician ? technician : 'User'}
172
+ </span>
173
+ <span className="shrink-0 text-ods-text-secondary text-h6">{formatTime(message.at)}</span>
174
+ </div>
175
+ <p className="w-full break-words text-ods-text-primary text-h6">{message.text}</p>
176
+ </div>
177
+ );
178
+ }