@linktr.ee/messaging-react 1.31.0-rc-1776748331 → 1.32.0
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/{Card-B1gJkHn-.js → Card-Cn2va-Qr.js} +83 -83
- package/dist/Card-Cn2va-Qr.js.map +1 -0
- package/dist/index.js +79 -63
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ChannelView.stories.tsx +38 -7
- package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +127 -0
- package/src/components/CustomMessageInput/index.tsx +25 -8
- package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -6
- package/dist/Card-B1gJkHn-.js.map +0 -1
package/package.json
CHANGED
|
@@ -39,7 +39,8 @@ const mockUser = {
|
|
|
39
39
|
const createMockChannel = async (
|
|
40
40
|
client: StreamChat,
|
|
41
41
|
hasMessages = true,
|
|
42
|
-
followerStatus?: string | boolean
|
|
42
|
+
followerStatus?: string | boolean,
|
|
43
|
+
isFrozen = false
|
|
43
44
|
) => {
|
|
44
45
|
const participant = mockParticipants[0]
|
|
45
46
|
|
|
@@ -105,6 +106,7 @@ const createMockChannel = async (
|
|
|
105
106
|
// Prepare channel data with optional follower status
|
|
106
107
|
const channelData: Record<string, unknown> = {
|
|
107
108
|
members: [mockUser.id, participant.id],
|
|
109
|
+
frozen: isFrozen,
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
// Add follower status if provided
|
|
@@ -155,10 +157,13 @@ const createMockChannel = async (
|
|
|
155
157
|
return channel
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
type TemplateProps = ComponentProps & {
|
|
160
|
+
type TemplateProps = ComponentProps & {
|
|
161
|
+
followerStatus?: string | boolean
|
|
162
|
+
isFrozen?: boolean
|
|
163
|
+
}
|
|
159
164
|
|
|
160
165
|
const Template: StoryFn<TemplateProps> = (args) => {
|
|
161
|
-
const { followerStatus, ...channelViewProps } = args
|
|
166
|
+
const { followerStatus, isFrozen = false, ...channelViewProps } = args
|
|
162
167
|
const [client] = React.useState(() => {
|
|
163
168
|
const client = new StreamChat('mock-api-key', {
|
|
164
169
|
allowServerSideConnect: true,
|
|
@@ -171,10 +176,12 @@ const Template: StoryFn<TemplateProps> = (args) => {
|
|
|
171
176
|
const [channel, setChannel] = React.useState<ChannelType | null>(null)
|
|
172
177
|
|
|
173
178
|
useEffect(() => {
|
|
174
|
-
createMockChannel(client, true, followerStatus).then(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
179
|
+
createMockChannel(client, true, followerStatus, isFrozen).then(
|
|
180
|
+
(mockChannel) => {
|
|
181
|
+
setChannel(mockChannel)
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
}, [client, followerStatus, isFrozen])
|
|
178
185
|
|
|
179
186
|
if (!channel) {
|
|
180
187
|
return <div>Loading...</div>
|
|
@@ -524,3 +531,27 @@ NoFollowerStatus.parameters = {
|
|
|
524
531
|
},
|
|
525
532
|
},
|
|
526
533
|
}
|
|
534
|
+
|
|
535
|
+
export const FrozenChannel: StoryFn<TemplateProps> = Template.bind({})
|
|
536
|
+
FrozenChannel.args = {
|
|
537
|
+
showBackButton: false,
|
|
538
|
+
isFrozen: true,
|
|
539
|
+
renderMessageInputActions: (channel) => (
|
|
540
|
+
<button
|
|
541
|
+
onClick={() => console.log('Custom action clicked', channel.id)}
|
|
542
|
+
className="p-2 hover:bg-sand rounded-lg"
|
|
543
|
+
aria-label="Attach file"
|
|
544
|
+
type="button"
|
|
545
|
+
>
|
|
546
|
+
📎
|
|
547
|
+
</button>
|
|
548
|
+
),
|
|
549
|
+
}
|
|
550
|
+
FrozenChannel.parameters = {
|
|
551
|
+
docs: {
|
|
552
|
+
description: {
|
|
553
|
+
story:
|
|
554
|
+
'Channel view for a frozen conversation. The message composer renders in its disabled frozen state while the rest of the conversation remains readable.',
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { renderWithProviders, screen } from '../../test/utils'
|
|
5
|
+
|
|
6
|
+
import { CustomMessageInput } from '.'
|
|
7
|
+
|
|
8
|
+
let mockChannelData: Record<string, unknown> = {}
|
|
9
|
+
|
|
10
|
+
vi.mock('stream-chat-react', () => ({
|
|
11
|
+
MessageInput: ({
|
|
12
|
+
Input,
|
|
13
|
+
}: {
|
|
14
|
+
Input: React.ComponentType
|
|
15
|
+
}) => (
|
|
16
|
+
<div data-testid="stream-message-input">
|
|
17
|
+
<Input />
|
|
18
|
+
</div>
|
|
19
|
+
),
|
|
20
|
+
SimpleAttachmentSelector: () => (
|
|
21
|
+
<div data-testid="simple-attachment-selector" />
|
|
22
|
+
),
|
|
23
|
+
TextareaComposer: ({
|
|
24
|
+
maxRows: _maxRows,
|
|
25
|
+
...props
|
|
26
|
+
}: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { maxRows?: number }) => (
|
|
27
|
+
<textarea data-testid="textarea-composer" {...props} />
|
|
28
|
+
),
|
|
29
|
+
AttachmentPreviewList: () => <div data-testid="attachment-preview-list" />,
|
|
30
|
+
QuotedMessagePreview: () => <div data-testid="quoted-message-preview" />,
|
|
31
|
+
useChannelStateContext: () => ({
|
|
32
|
+
channel: { data: mockChannelData },
|
|
33
|
+
}),
|
|
34
|
+
useMessageInputContext: () => ({
|
|
35
|
+
handleSubmit: vi.fn(),
|
|
36
|
+
}),
|
|
37
|
+
useMessageComposerHasSendableData: () => false,
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
vi.mock('../CustomLinkPreviewList', () => ({
|
|
41
|
+
CustomLinkPreviewList: () => <div data-testid="custom-link-preview-list" />,
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
describe('CustomMessageInput', () => {
|
|
45
|
+
it('renders the interactive message input when channel is not frozen', () => {
|
|
46
|
+
mockChannelData = {}
|
|
47
|
+
|
|
48
|
+
const { container } = renderWithProviders(<CustomMessageInput />)
|
|
49
|
+
|
|
50
|
+
const messageInput = container.firstElementChild
|
|
51
|
+
expect(messageInput).not.toHaveAttribute('aria-disabled')
|
|
52
|
+
expect(messageInput).not.toHaveAttribute('inert')
|
|
53
|
+
expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('renders the frozen message input when channel is frozen', () => {
|
|
57
|
+
mockChannelData = { frozen: true }
|
|
58
|
+
|
|
59
|
+
const { container } = renderWithProviders(<CustomMessageInput />)
|
|
60
|
+
|
|
61
|
+
const messageInput = container.firstElementChild
|
|
62
|
+
expect(messageInput).toHaveAttribute('aria-disabled', 'true')
|
|
63
|
+
expect(messageInput).toHaveAttribute('inert')
|
|
64
|
+
expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('makes the textarea read-only and disables send when channel is frozen', () => {
|
|
68
|
+
mockChannelData = { frozen: true }
|
|
69
|
+
|
|
70
|
+
renderWithProviders(<CustomMessageInput />)
|
|
71
|
+
|
|
72
|
+
const textarea = screen.getByTestId('textarea-composer')
|
|
73
|
+
expect(textarea).toHaveAttribute('aria-disabled', 'true')
|
|
74
|
+
expect(textarea).toHaveAttribute('readonly')
|
|
75
|
+
expect(textarea).toHaveAttribute('tabindex', '-1')
|
|
76
|
+
expect(textarea).not.toHaveAttribute('autofocus')
|
|
77
|
+
|
|
78
|
+
const sendButton = screen.getByRole('button', { name: /send/i })
|
|
79
|
+
expect(sendButton).toBeDisabled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('renders the existing attachment selector when channel is frozen', () => {
|
|
83
|
+
mockChannelData = { frozen: true }
|
|
84
|
+
|
|
85
|
+
renderWithProviders(<CustomMessageInput />)
|
|
86
|
+
|
|
87
|
+
expect(screen.getByTestId('simple-attachment-selector')).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('renders adjacent actions inside the disabled container when frozen', () => {
|
|
91
|
+
mockChannelData = { frozen: true }
|
|
92
|
+
|
|
93
|
+
const { container } = renderWithProviders(
|
|
94
|
+
<CustomMessageInput
|
|
95
|
+
renderActions={() => (
|
|
96
|
+
<button data-testid="custom-action" type="button">
|
|
97
|
+
Action
|
|
98
|
+
</button>
|
|
99
|
+
)}
|
|
100
|
+
/>
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const messageInput = container.firstElementChild
|
|
104
|
+
const action = screen.getByTestId('custom-action')
|
|
105
|
+
|
|
106
|
+
expect(messageInput).toHaveAttribute('aria-disabled', 'true')
|
|
107
|
+
expect(messageInput?.contains(action)).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('renders adjacent actions in the interactive container when not frozen', () => {
|
|
111
|
+
mockChannelData = {}
|
|
112
|
+
|
|
113
|
+
const { container } = renderWithProviders(
|
|
114
|
+
<CustomMessageInput
|
|
115
|
+
renderActions={() => (
|
|
116
|
+
<button data-testid="custom-action" type="button">
|
|
117
|
+
Action
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
120
|
+
/>
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const messageInput = container.firstElementChild
|
|
124
|
+
expect(messageInput).not.toHaveAttribute('aria-disabled')
|
|
125
|
+
expect(screen.getByTestId('custom-action')).toBeInTheDocument()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
QuotedMessagePreview,
|
|
7
7
|
SimpleAttachmentSelector,
|
|
8
8
|
TextareaComposer,
|
|
9
|
+
useChannelStateContext,
|
|
9
10
|
useMessageComposerHasSendableData,
|
|
10
11
|
useMessageInputContext,
|
|
11
12
|
} from 'stream-chat-react'
|
|
@@ -13,8 +14,11 @@ import {
|
|
|
13
14
|
import { CustomLinkPreviewList } from '../CustomLinkPreviewList'
|
|
14
15
|
|
|
15
16
|
const CustomMessageInputInner: React.FC = () => {
|
|
17
|
+
const { channel } = useChannelStateContext()
|
|
18
|
+
const isFrozen = channel?.data?.frozen === true
|
|
16
19
|
const { handleSubmit } = useMessageInputContext()
|
|
17
20
|
const hasSendableData = useMessageComposerHasSendableData()
|
|
21
|
+
const isSendDisabled = isFrozen || !hasSendableData
|
|
18
22
|
|
|
19
23
|
return (
|
|
20
24
|
<>
|
|
@@ -28,20 +32,23 @@ const CustomMessageInputInner: React.FC = () => {
|
|
|
28
32
|
<div className="flex">
|
|
29
33
|
<div className="w-full ml-2 mr-4 self-center leading-[0]">
|
|
30
34
|
<TextareaComposer
|
|
35
|
+
aria-disabled={isFrozen || undefined}
|
|
31
36
|
className="w-full resize-none outline-none leading-6"
|
|
32
37
|
// While this might usually be considered an anti-pattern, in most
|
|
33
38
|
// cases, when a message thread is rendered, we want the input to
|
|
34
39
|
// gain focus automatically.
|
|
35
40
|
// eslint-disable-next-line jsx-a11y/no-autofocus
|
|
36
|
-
autoFocus
|
|
41
|
+
autoFocus={!isFrozen}
|
|
37
42
|
maxRows={4}
|
|
43
|
+
readOnly={isFrozen}
|
|
44
|
+
tabIndex={isFrozen ? -1 : undefined}
|
|
38
45
|
/>
|
|
39
46
|
</div>
|
|
40
47
|
<button
|
|
41
48
|
aria-label="Send"
|
|
42
49
|
className="str-chat__send-button mt-auto flex justify-center items-center flex-shrink-0 rounded-full size-8 bg-[#121110] disabled:bg-[#F1F0EE] disabled:text-black/20 text-white focus-ring"
|
|
43
50
|
data-testid="send-button"
|
|
44
|
-
disabled={
|
|
51
|
+
disabled={isSendDisabled}
|
|
45
52
|
onClick={handleSubmit}
|
|
46
53
|
type="button"
|
|
47
54
|
>
|
|
@@ -59,9 +66,19 @@ export interface CustomMessageInputProps {
|
|
|
59
66
|
|
|
60
67
|
export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
|
|
61
68
|
renderActions,
|
|
62
|
-
}) =>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
}) => {
|
|
70
|
+
const { channel } = useChannelStateContext()
|
|
71
|
+
const isFrozen = channel?.data?.frozen === true
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
// @ts-expect-error Only React 19 onwards has `inert` in its types.
|
|
76
|
+
inert={isFrozen ? '' : undefined}
|
|
77
|
+
aria-disabled={isFrozen || undefined}
|
|
78
|
+
className="message-input flex items-center gap-2 p-4 aria-disabled:opacity-40"
|
|
79
|
+
>
|
|
80
|
+
{renderActions?.()}
|
|
81
|
+
<MessageInput Input={CustomMessageInputInner} />
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -72,11 +72,9 @@ const VisitorCard: React.FC<VisitorCardProps> = ({
|
|
|
72
72
|
|
|
73
73
|
const fetchSource = useCallback(async (): Promise<void> => {
|
|
74
74
|
if (fetchingRef.current) return
|
|
75
|
-
console.log('[LOCKED ATTACHMENT] fetchSource: starting fetch')
|
|
76
75
|
fetchingRef.current = true
|
|
77
76
|
try {
|
|
78
77
|
const result = await onFetchSourceRef.current?.()
|
|
79
|
-
console.log('[LOCKED ATTACHMENT] fetchSource: result received', { hasResult: Boolean(result) })
|
|
80
78
|
if (result) setSource(result)
|
|
81
79
|
} finally {
|
|
82
80
|
fetchingRef.current = false
|
|
@@ -91,19 +89,15 @@ const VisitorCard: React.FC<VisitorCardProps> = ({
|
|
|
91
89
|
return
|
|
92
90
|
}
|
|
93
91
|
|
|
94
|
-
console.log('[LOCKED ATTACHMENT] paymentStatus changed', { paymentStatus })
|
|
95
92
|
if (paymentStatus === 'paid') {
|
|
96
|
-
console.log('[LOCKED ATTACHMENT] paymentStatus=paid: auto-fetching source')
|
|
97
93
|
void fetchSource()
|
|
98
94
|
}
|
|
99
95
|
}, [paymentStatus, fetchSource])
|
|
100
96
|
|
|
101
97
|
const handleUnlockClick = useCallback(() => {
|
|
102
98
|
if (paymentStatus === 'paid') {
|
|
103
|
-
console.log('[LOCKED ATTACHMENT] handleUnlockClick: already paid, fetching source')
|
|
104
99
|
void fetchSource()
|
|
105
100
|
} else {
|
|
106
|
-
console.log('[LOCKED ATTACHMENT] handleUnlockClick: not paid, opening checkout')
|
|
107
101
|
onUnlockClick?.()
|
|
108
102
|
}
|
|
109
103
|
}, [paymentStatus, onUnlockClick, fetchSource])
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Card-B1gJkHn-.js","sources":["../src/components/LockedAttachment/components/Visitor/CardActions.tsx","../src/components/LockedAttachment/components/Visitor/CardThumbnailPreview.tsx","../src/components/LockedAttachment/components/Visitor/CardImagePreview.tsx","../src/components/LockedAttachment/components/Visitor/CardMediaPreview.tsx","../src/components/LockedAttachment/components/Visitor/Card.tsx"],"sourcesContent":["import { DownloadSimpleIcon, LockSimpleIcon } from '@phosphor-icons/react'\nimport React from 'react'\n\ninterface CardActionsProps {\n isLocked: boolean\n isUnlocking?: boolean\n sourceUrl?: string\n redeemUrl?: string\n onUnlockClicked?: () => void\n onDownloadClicked?: () => void\n}\n\nconst CardActions: React.FC<CardActionsProps> = (props) => {\n const {\n isLocked,\n isUnlocking = false,\n sourceUrl,\n redeemUrl,\n onUnlockClicked,\n onDownloadClicked,\n } = props\n\n if (isLocked && onUnlockClicked != null) {\n return (\n <button\n type=\"button\"\n onClick={onUnlockClicked}\n disabled={isUnlocking}\n className=\"mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full bg-[#121110] px-4 text-sm font-medium leading-none text-white hover:bg-[#2a2928] disabled:opacity-70\"\n >\n {isUnlocking ? (\n <LoadingDots />\n ) : (\n <React.Fragment>\n <LockSimpleIcon className=\"size-4\" weight=\"fill\" />\n Unlock\n </React.Fragment>\n )}\n </button>\n )\n }\n\n if (!isLocked && onDownloadClicked != null && sourceUrl != null) {\n return (\n <a\n href={redeemUrl ?? sourceUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={onDownloadClicked}\n className=\"mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full bg-[#121110] px-4 text-sm font-medium leading-none !text-white hover:bg-[#2a2928]\"\n >\n <DownloadSimpleIcon className=\"size-4\" weight=\"bold\" />\n Download\n </a>\n )\n }\n\n return null\n}\n\nconst LoadingDots: React.FC = () => {\n return (\n <span className=\"flex items-center gap-1\">\n <span className=\"size-1 rounded-full bg-white animate-bounce [animation-delay:-0.3s]\" />\n <span className=\"size-1 rounded-full bg-white animate-bounce [animation-delay:-0.15s]\" />\n <span className=\"size-1 rounded-full bg-white animate-bounce\" />\n </span>\n )\n}\n\nexport default CardActions\n","import React from 'react'\n\nimport { renderTypeIcon } from '../../utils/icons'\n\ninterface ThumbnailPreviewProps {\n thumbnailUrl?: string\n mimeType: string\n LockIcon?: React.ElementType\n}\n\nconst ThumbnailPreview: React.FC<ThumbnailPreviewProps> = (props) => {\n const { thumbnailUrl, mimeType, LockIcon } = props\n\n return (\n <div className=\"relative aspect-video overflow-hidden bg-black/5\">\n {thumbnailUrl != null ? (\n <img\n src={thumbnailUrl}\n alt=\"\"\n className=\"absolute inset-0 h-full w-full object-cover\"\n />\n ) : (\n <div className=\"absolute inset-0 flex items-center justify-center\">\n {renderTypeIcon(mimeType, {\n className: 'size-12 text-black/20',\n weight: 'regular',\n })}\n </div>\n )}\n {LockIcon != null ? <LockOverlay icon={LockIcon} /> : null}\n </div>\n )\n}\n\nconst LockOverlay: React.FC<{ icon: React.ElementType }> = ({ icon: Icon }) => {\n return (\n <div className=\"absolute inset-0 bg-black/30\">\n <div className=\"absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60\">\n <Icon className=\"size-4 text-white\" weight=\"fill\" />\n </div>\n </div>\n )\n}\n\nexport default ThumbnailPreview\n","import React, { useState } from 'react'\n\nimport ThumbnailPreview from './CardThumbnailPreview'\n\ninterface ImagePreviewProps {\n sourceUrl?: string\n thumbnailUrl?: string\n mimeType: string\n title?: string\n LockIcon?: React.ElementType\n}\n\nconst ImagePreview: React.FC<ImagePreviewProps> = (props) => {\n const { sourceUrl, thumbnailUrl, mimeType, title, LockIcon } = props\n const [sourceReady, setSourceReady] = useState(false)\n\n if (LockIcon != null) {\n return (\n <ThumbnailPreview\n thumbnailUrl={thumbnailUrl}\n mimeType={mimeType}\n LockIcon={LockIcon}\n />\n )\n }\n\n return (\n <div className=\"relative overflow-hidden bg-black/5\">\n <img\n src={sourceUrl}\n alt={title}\n className={`block w-full transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}\n onLoad={() => setSourceReady(true)}\n />\n </div>\n )\n}\n\nexport default ImagePreview\n","import React from 'react'\n\nimport MediaPlayer from '../MediaPlayer'\n\nimport ThumbnailPreview from './CardThumbnailPreview'\n\ninterface MediaPreviewProps {\n sourceUrl?: string\n thumbnailUrl?: string\n mimeType: string\n LockIcon?: React.ElementType\n}\n\nconst MediaPreview: React.FC<MediaPreviewProps> = (props) => {\n const { sourceUrl, thumbnailUrl, mimeType, LockIcon } = props\n\n if (LockIcon != null) {\n return (\n <ThumbnailPreview\n thumbnailUrl={thumbnailUrl}\n mimeType={mimeType}\n LockIcon={LockIcon}\n />\n )\n }\n\n return (\n <MediaPlayer\n source={sourceUrl ?? ''}\n mimeType={mimeType}\n poster={thumbnailUrl}\n />\n )\n}\n\nexport default MediaPreview\n","import {\n CheckCircleIcon,\n LockOpenIcon,\n LockSimpleIcon,\n} from '@phosphor-icons/react'\nimport React, { useCallback, useEffect, useRef, useState } from 'react'\n\nimport type {\n LockedAttachmentBaseProps,\n LockedAttachmentSource,\n PaymentStatus,\n} from '../../types'\nimport { renderTypeIcon } from '../../utils/icons'\nimport { getSourceType } from '../../utils/mimeType'\n\nimport CardActions from './CardActions'\nimport ImagePreview from './CardImagePreview'\nimport MediaPreview from './CardMediaPreview'\nimport ThumbnailPreview from './CardThumbnailPreview'\n\nexport interface VisitorCardProps extends LockedAttachmentBaseProps {\n /**\n * Called when the visitor clicks Unlock on an unpaid attachment.\n * Use this to open a checkout flow. Omit to hide the Unlock button.\n */\n onUnlockClick?: () => void\n /**\n * Called to fetch the attachment source — fired automatically when\n * paymentStatus transitions to 'paid', or immediately on click when\n * paymentStatus is already 'paid'. Return a LockedAttachmentSource to\n * unlock the card.\n */\n onFetchSource?: () => Promise<LockedAttachmentSource | void>\n /**\n * Called when the visitor clicks Download on an unlocked card.\n * Omit to hide the Download button.\n */\n onDownloadClick?: () => void\n /**\n * When true, shows loading dots on the Unlock button.\n * Driven by the LockedAttachmentContext (e.g. checkout in progress, payment processing).\n */\n isUnlocking?: boolean\n}\n\nfunction getLockIcon(paymentStatus?: PaymentStatus): React.ElementType {\n return paymentStatus === 'paid' ? LockOpenIcon : LockSimpleIcon\n}\n\nconst VisitorCard: React.FC<VisitorCardProps> = ({\n title,\n amountText,\n thumbnailUrl,\n mimeType = 'application/octet-stream',\n detail,\n onUnlockClick,\n onFetchSource,\n onDownloadClick,\n paymentStatus,\n isUnlocking = false,\n}) => {\n const [source, setSource] = useState<LockedAttachmentSource | undefined>()\n const hasMounted = useRef(false)\n const fetchingRef = useRef(false)\n // Stable ref so fetchSource doesn't change identity when onFetchSource prop changes\n const onFetchSourceRef = useRef(onFetchSource)\n onFetchSourceRef.current = onFetchSource\n\n const isLocked = source === undefined\n const sourceType = getSourceType(mimeType)\n const LockIcon = isLocked ? getLockIcon(paymentStatus) : undefined\n\n const fetchSource = useCallback(async (): Promise<void> => {\n if (fetchingRef.current) return\n console.log('[LOCKED ATTACHMENT] fetchSource: starting fetch')\n fetchingRef.current = true\n try {\n const result = await onFetchSourceRef.current?.()\n console.log('[LOCKED ATTACHMENT] fetchSource: result received', { hasResult: Boolean(result) })\n if (result) setSource(result)\n } finally {\n fetchingRef.current = false\n }\n }, []) // stable — reads onFetchSource via ref\n\n // When paymentStatus transitions to 'paid' (e.g. after checkout completes),\n // automatically fetch the source. Skipped on mount.\n useEffect(() => {\n if (!hasMounted.current) {\n hasMounted.current = true\n return\n }\n\n console.log('[LOCKED ATTACHMENT] paymentStatus changed', { paymentStatus })\n if (paymentStatus === 'paid') {\n console.log('[LOCKED ATTACHMENT] paymentStatus=paid: auto-fetching source')\n void fetchSource()\n }\n }, [paymentStatus, fetchSource])\n\n const handleUnlockClick = useCallback(() => {\n if (paymentStatus === 'paid') {\n console.log('[LOCKED ATTACHMENT] handleUnlockClick: already paid, fetching source')\n void fetchSource()\n } else {\n console.log('[LOCKED ATTACHMENT] handleUnlockClick: not paid, opening checkout')\n onUnlockClick?.()\n }\n }, [paymentStatus, onUnlockClick, fetchSource])\n\n let mediaPreview: React.ReactNode\n if (sourceType === 'image') {\n mediaPreview = (\n <ImagePreview\n key={source?.sourceUrl}\n sourceUrl={source?.sourceUrl}\n thumbnailUrl={thumbnailUrl}\n mimeType={mimeType}\n title={title}\n LockIcon={LockIcon}\n />\n )\n } else if (sourceType === 'document') {\n mediaPreview = (\n <ThumbnailPreview\n key={source?.sourceUrl}\n thumbnailUrl={thumbnailUrl}\n mimeType={mimeType}\n LockIcon={LockIcon}\n />\n )\n } else {\n mediaPreview = (\n <MediaPreview\n key={source?.sourceUrl}\n sourceUrl={source?.sourceUrl}\n thumbnailUrl={thumbnailUrl}\n mimeType={mimeType}\n LockIcon={LockIcon}\n />\n )\n }\n\n return (\n <div className=\"w-[280px] select-none overflow-hidden rounded-[24px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_4px_8px_rgba(0,0,0,0.06)]\">\n {mediaPreview}\n <div className=\"px-4 pb-3 pt-3\">\n <p className=\"mb-1.5 truncate text-base font-medium text-black\">\n {title}\n </p>\n <div className=\"flex items-center gap-1\">\n {renderTypeIcon(mimeType, {\n className: 'size-5 shrink-0 text-black/55',\n weight: 'regular',\n })}\n {detail != null ? (\n <span className=\"text-xs font-medium text-black/55\">{detail}</span>\n ) : null}\n {paymentStatus === 'paid' ? (\n <>\n <span className=\"text-xs font-medium text-black/55\">•</span>\n <span className=\"text-xs font-medium text-[#008236]\">\n Purchased\n </span>\n <CheckCircleIcon\n className=\"size-4 text-[#008236]\"\n weight=\"bold\"\n />\n </>\n ) : amountText != null ? (\n <>\n <span className=\"text-xs font-medium text-black/55\">•</span>\n <span className=\"text-xs font-medium text-black/55\">\n {amountText}\n </span>\n </>\n ) : null}\n </div>\n <CardActions\n isLocked={isLocked}\n isUnlocking={isUnlocking}\n sourceUrl={source?.sourceUrl}\n redeemUrl={source?.redeemUrl}\n onUnlockClicked={handleUnlockClick}\n onDownloadClicked={onDownloadClick}\n />\n </div>\n </div>\n )\n}\n\nexport default VisitorCard\n"],"names":["CardActions","props","isLocked","isUnlocking","sourceUrl","redeemUrl","onUnlockClicked","onDownloadClicked","jsx","LoadingDots","jsxs","React","LockSimpleIcon","DownloadSimpleIcon","ThumbnailPreview","thumbnailUrl","mimeType","LockIcon","LockOverlay","Icon","ImagePreview","title","sourceReady","setSourceReady","useState","MediaPreview","MediaPlayer","getLockIcon","paymentStatus","LockOpenIcon","VisitorCard","amountText","detail","onUnlockClick","onFetchSource","onDownloadClick","source","setSource","hasMounted","useRef","fetchingRef","onFetchSourceRef","sourceType","getSourceType","fetchSource","useCallback","result","_a","useEffect","handleUnlockClick","mediaPreview","renderTypeIcon","Fragment","CheckCircleIcon"],"mappings":"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"}
|