@linktr.ee/messaging-react 1.26.1 → 1.28.0-rc-1776225927

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 (58) hide show
  1. package/dist/Creator-D38dWn2X.js +318 -0
  2. package/dist/Creator-D38dWn2X.js.map +1 -0
  3. package/dist/MediaPlayer-DE9MC6k6.js +599 -0
  4. package/dist/MediaPlayer-DE9MC6k6.js.map +1 -0
  5. package/dist/Preview-DqAv16NS.js +87 -0
  6. package/dist/Preview-DqAv16NS.js.map +1 -0
  7. package/dist/Visitor-BG-9-3HU.js +199 -0
  8. package/dist/Visitor-BG-9-3HU.js.map +1 -0
  9. package/dist/dash.all.min-Duv4lvGS.js +18858 -0
  10. package/dist/dash.all.min-Duv4lvGS.js.map +1 -0
  11. package/dist/hls-Bogc7CBn.js +21710 -0
  12. package/dist/hls-Bogc7CBn.js.map +1 -0
  13. package/dist/index-Da-xN4Yq.js +16142 -0
  14. package/dist/index-Da-xN4Yq.js.map +1 -0
  15. package/dist/index-Dj9rqWcU.js +69 -0
  16. package/dist/index-Dj9rqWcU.js.map +1 -0
  17. package/dist/index.d.ts +74 -10
  18. package/dist/index.js +979 -934
  19. package/dist/index.js.map +1 -1
  20. package/dist/mixin-B6jYfIcp.js +808 -0
  21. package/dist/mixin-B6jYfIcp.js.map +1 -0
  22. package/dist/react-BxlQMOfz.js +419 -0
  23. package/dist/react-BxlQMOfz.js.map +1 -0
  24. package/dist/react-COAP-MIW.js +377 -0
  25. package/dist/react-COAP-MIW.js.map +1 -0
  26. package/dist/react-Cn4WlMcl.js +3108 -0
  27. package/dist/react-Cn4WlMcl.js.map +1 -0
  28. package/dist/react-CwTJArKY.js +459 -0
  29. package/dist/react-CwTJArKY.js.map +1 -0
  30. package/dist/react-DkfS_atT.js +373 -0
  31. package/dist/react-DkfS_atT.js.map +1 -0
  32. package/dist/react-Pea5fum1.js +286 -0
  33. package/dist/react-Pea5fum1.js.map +1 -0
  34. package/dist/react-RiBbsUDd.js +534 -0
  35. package/dist/react-RiBbsUDd.js.map +1 -0
  36. package/dist/react-dS1WBxxz.js +238 -0
  37. package/dist/react-dS1WBxxz.js.map +1 -0
  38. package/package.json +2 -1
  39. package/src/components/ChannelView.tsx +12 -2
  40. package/src/components/CustomMessage/CustomMessage.stories.tsx +173 -41
  41. package/src/components/CustomMessage/MessageTag.tsx +5 -0
  42. package/src/components/CustomMessage/index.tsx +43 -4
  43. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +343 -0
  44. package/src/components/LockedAttachment/components/Creator.tsx +469 -0
  45. package/src/components/LockedAttachment/components/MediaPlayer.tsx +359 -0
  46. package/src/components/LockedAttachment/components/Visitor.tsx +356 -0
  47. package/src/components/LockedAttachment/index.tsx +39 -0
  48. package/src/components/LockedAttachment/types.ts +17 -0
  49. package/src/components/LockedAttachment/utils/icons.ts +53 -0
  50. package/src/components/LockedAttachment/utils/mimeType.test.ts +119 -0
  51. package/src/components/LockedAttachment/utils/mimeType.ts +37 -0
  52. package/src/components/ParticipantPicker/index.tsx +8 -1
  53. package/src/hooks/useParticipants.ts +3 -2
  54. package/src/index.ts +4 -0
  55. package/src/stories/decorators/storyUser.tsx +37 -0
  56. package/src/stream-custom-data.ts +9 -3
  57. package/src/types.ts +20 -1
  58. package/src/utils/isDevBuild.ts +10 -0
@@ -0,0 +1,359 @@
1
+ import { CircleNotchIcon, PauseIcon, PlayIcon } from '@phosphor-icons/react'
2
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
3
+ import ReactPlayer from 'react-player'
4
+
5
+ import { isDevBuild } from '../../../utils/isDevBuild'
6
+ import { renderTypeIcon } from '../utils/icons'
7
+ import { getSourceType, type AttachmentSourceType } from '../utils/mimeType'
8
+
9
+ const getPlayerBg = (sourceType: AttachmentSourceType, poster?: string) =>
10
+ sourceType === 'audio' && !poster ? 'bg-black/5' : 'bg-black'
11
+
12
+ function getClientXFromEvent(
13
+ e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
14
+ ): number {
15
+ if ('touches' in e) {
16
+ return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0
17
+ }
18
+ return e.clientX
19
+ }
20
+
21
+ export interface MediaPlayerProps {
22
+ source: string
23
+ mimeType: string
24
+ poster?: string
25
+ autoPlay?: boolean
26
+ /** Controlled playing state. When provided, syncs to internal play/pause. */
27
+ playing?: boolean
28
+ loop?: boolean
29
+ controls?: boolean
30
+ showProgress?: boolean
31
+ onContainerClick?: () => void
32
+ /** When true, requests muted playback (helps autoplay policies on video). */
33
+ muted?: boolean
34
+ }
35
+
36
+ const MediaPlayer: React.FC<MediaPlayerProps> = ({
37
+ source,
38
+ mimeType,
39
+ poster,
40
+ autoPlay = false,
41
+ playing: playingProp,
42
+ loop = false,
43
+ controls = true,
44
+ showProgress = false,
45
+ onContainerClick,
46
+ muted = false,
47
+ }) => {
48
+ const sourceType = getSourceType(mimeType)
49
+ const [playing, setPlaying] = useState(autoPlay)
50
+
51
+ // Sync controlled playing prop to internal state
52
+ const prevPlayingPropRef = useRef(playingProp)
53
+ useEffect(() => {
54
+ if (
55
+ playingProp !== undefined &&
56
+ playingProp !== prevPlayingPropRef.current
57
+ ) {
58
+ prevPlayingPropRef.current = playingProp
59
+ setPlaying(playingProp)
60
+ }
61
+ }, [playingProp])
62
+ const [played, setPlayed] = useState(0)
63
+ const [seeking, setSeeking] = useState(false)
64
+ const [scrubberHovered, setScrubberHovered] = useState(false)
65
+ const [videoAspect, setVideoAspect] = useState<number | null>(null)
66
+ const [buffering, setBuffering] = useState(false)
67
+ /** Set when autoplay/play() was rejected so user can start via gesture (no controls UI). */
68
+ const [manualPlayRequired, setManualPlayRequired] = useState(false)
69
+ const playerRef = useRef<HTMLVideoElement>(null)
70
+ const trackRef = useRef<HTMLDivElement>(null)
71
+ const rafRef = useRef<number | null>(null)
72
+
73
+ useEffect(() => {
74
+ setManualPlayRequired(false)
75
+ }, [source])
76
+
77
+ useEffect(() => {
78
+ if (!playing) {
79
+ if (rafRef.current !== null) {
80
+ cancelAnimationFrame(rafRef.current)
81
+ rafRef.current = null
82
+ }
83
+ return
84
+ }
85
+ const tick = () => {
86
+ const el = playerRef.current
87
+ if (el && el.duration && !seeking) setPlayed(el.currentTime / el.duration)
88
+ rafRef.current = requestAnimationFrame(tick)
89
+ }
90
+ rafRef.current = requestAnimationFrame(tick)
91
+ return () => {
92
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
93
+ }
94
+ }, [playing, seeking])
95
+
96
+ // ReactPlayer v3 uses native HTML media elements and does not support a
97
+ // declarative `playing` prop — playback must be driven imperatively.
98
+ useEffect(() => {
99
+ const el = playerRef.current
100
+ if (!el) return
101
+ if (playing) {
102
+ void el.play().catch((err) => {
103
+ setPlaying(false)
104
+ setManualPlayRequired(true)
105
+ if (isDevBuild()) {
106
+ console.debug('[MediaPlayer] play() failed', err)
107
+ }
108
+ })
109
+ } else {
110
+ el.pause()
111
+ }
112
+ }, [playing])
113
+
114
+ const startPlaybackFromGesture = useCallback(() => {
115
+ setManualPlayRequired(false)
116
+ setPlaying(true)
117
+ }, [])
118
+
119
+ const getFraction = useCallback(
120
+ (e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
121
+ const track = trackRef.current
122
+ if (!track) return 0
123
+ const rect = track.getBoundingClientRect()
124
+ return Math.max(
125
+ 0,
126
+ Math.min(1, (getClientXFromEvent(e) - rect.left) / rect.width)
127
+ )
128
+ },
129
+ []
130
+ )
131
+
132
+ const seekTo = useCallback((fraction: number) => {
133
+ const el = playerRef.current
134
+ if (el && el.duration) el.currentTime = fraction * el.duration
135
+ }, [])
136
+
137
+ const handleTrackPointerDown = (
138
+ e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
139
+ ) => {
140
+ e.stopPropagation()
141
+ setSeeking(true)
142
+ const fraction = getFraction(e)
143
+ setPlayed(fraction)
144
+ seekTo(fraction)
145
+ }
146
+
147
+ useEffect(() => {
148
+ if (!seeking) return
149
+ const onMove = (e: MouseEvent | TouchEvent) => setPlayed(getFraction(e))
150
+ const onUp = (e: MouseEvent | TouchEvent) => {
151
+ setSeeking(false)
152
+ seekTo(getFraction(e))
153
+ }
154
+ window.addEventListener('mousemove', onMove)
155
+ window.addEventListener('mouseup', onUp)
156
+ window.addEventListener('touchmove', onMove, { passive: true })
157
+ window.addEventListener('touchend', onUp)
158
+ return () => {
159
+ window.removeEventListener('mousemove', onMove)
160
+ window.removeEventListener('mouseup', onUp)
161
+ window.removeEventListener('touchmove', onMove)
162
+ window.removeEventListener('touchend', onUp)
163
+ }
164
+ }, [seeking, getFraction, seekTo])
165
+
166
+ // Use natural aspect ratio once metadata loads, fall back to 16:9 before then.
167
+ const aspectStyle = videoAspect
168
+ ? { aspectRatio: String(videoAspect) }
169
+ : undefined
170
+ const aspectClass = !videoAspect ? ' aspect-video' : ''
171
+ const scrubberPercent = Math.round(played * 100)
172
+
173
+ return (
174
+ <div
175
+ role="button"
176
+ tabIndex={0}
177
+ className={`relative cursor-pointer overflow-hidden ${getPlayerBg(sourceType, poster)}${aspectClass}`}
178
+ style={aspectStyle}
179
+ onClick={() => {
180
+ if (manualPlayRequired) return
181
+ if (onContainerClick) {
182
+ onContainerClick()
183
+ return
184
+ }
185
+ if (controls) setPlaying((p) => !p)
186
+ }}
187
+ onKeyDown={(e) => {
188
+ if (e.key !== 'Enter' && e.key !== ' ') return
189
+ e.preventDefault()
190
+ if (manualPlayRequired) return
191
+ if (onContainerClick) {
192
+ onContainerClick()
193
+ return
194
+ }
195
+ if (controls) setPlaying((p) => !p)
196
+ }}
197
+ >
198
+ {poster && (
199
+ <img
200
+ src={poster}
201
+ alt=""
202
+ className="absolute inset-0 h-full w-full object-cover"
203
+ />
204
+ )}
205
+ {!poster && (
206
+ <div className="absolute inset-0 flex items-center justify-center">
207
+ {renderTypeIcon(mimeType, {
208
+ className: 'size-12 text-black/20',
209
+ weight: 'regular',
210
+ })}
211
+ </div>
212
+ )}
213
+ <div className="absolute inset-0">
214
+ <ReactPlayer
215
+ ref={playerRef}
216
+ src={source}
217
+ poster={poster}
218
+ loop={loop}
219
+ muted={muted}
220
+ playsInline
221
+ width="100%"
222
+ height="100%"
223
+ onLoadStart={() => setBuffering(true)}
224
+ onCanPlay={() => setBuffering(false)}
225
+ onWaiting={() => setBuffering(true)}
226
+ onPlay={() => setManualPlayRequired(false)}
227
+ onLoadedMetadata={() => {
228
+ const el = playerRef.current
229
+ if (el && el.videoWidth && el.videoHeight) {
230
+ setVideoAspect(el.videoWidth / el.videoHeight)
231
+ }
232
+ }}
233
+ onEnded={() => {
234
+ if (!loop) {
235
+ setPlaying(false)
236
+ setPlayed(0)
237
+ }
238
+ }}
239
+ />
240
+ </div>
241
+
242
+ {buffering && !manualPlayRequired && (
243
+ <div className="absolute inset-0 z-10 flex items-center justify-center">
244
+ <CircleNotchIcon
245
+ className="size-8 animate-spin text-white/80"
246
+ weight="bold"
247
+ />
248
+ </div>
249
+ )}
250
+
251
+ {manualPlayRequired && !controls && (
252
+ <div
253
+ className="absolute inset-0 z-30 flex cursor-pointer items-center justify-center bg-black/35"
254
+ role="button"
255
+ tabIndex={0}
256
+ aria-label="Play preview"
257
+ onClick={(e) => {
258
+ e.stopPropagation()
259
+ startPlaybackFromGesture()
260
+ }}
261
+ onKeyDown={(e) => {
262
+ if (e.key !== 'Enter' && e.key !== ' ') return
263
+ e.preventDefault()
264
+ e.stopPropagation()
265
+ startPlaybackFromGesture()
266
+ }}
267
+ >
268
+ <span className="flex size-16 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm">
269
+ <PlayIcon className="size-9 translate-x-0.5" weight="fill" />
270
+ </span>
271
+ </div>
272
+ )}
273
+
274
+ {showProgress && !controls && (
275
+ <div className="absolute inset-x-0 bottom-0 px-3 pb-2.5 pt-6 bg-gradient-to-t from-black/40 to-transparent">
276
+ <div
277
+ role="slider"
278
+ aria-label="Playback position"
279
+ aria-valuenow={scrubberPercent}
280
+ aria-valuemin={0}
281
+ aria-valuemax={100}
282
+ tabIndex={0}
283
+ ref={trackRef}
284
+ className="relative flex h-4 w-full cursor-pointer items-center"
285
+ onMouseDown={handleTrackPointerDown}
286
+ onTouchStart={handleTrackPointerDown}
287
+ onClick={(e) => e.stopPropagation()}
288
+ onKeyDown={(e) => {
289
+ if (e.key === 'ArrowRight') seekTo(Math.min(1, played + 0.05))
290
+ if (e.key === 'ArrowLeft') seekTo(Math.max(0, played - 0.05))
291
+ }}
292
+ >
293
+ <div className="w-full overflow-hidden rounded-full bg-white/30 h-1">
294
+ <div
295
+ className="h-full rounded-full bg-white"
296
+ style={{ width: `${scrubberPercent}%` }}
297
+ />
298
+ </div>
299
+ </div>
300
+ </div>
301
+ )}
302
+
303
+ {controls && (
304
+ <div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6 transition-all duration-200">
305
+ <button
306
+ type="button"
307
+ onClick={(e) => {
308
+ e.stopPropagation()
309
+ setPlaying((p) => !p)
310
+ }}
311
+ className="shrink-0 text-white"
312
+ aria-label={playing ? 'Pause' : 'Play'}
313
+ >
314
+ {playing ? (
315
+ <PauseIcon className="size-5" weight="fill" />
316
+ ) : (
317
+ <PlayIcon className="size-5 translate-x-px" weight="fill" />
318
+ )}
319
+ </button>
320
+
321
+ <div
322
+ role="slider"
323
+ aria-label="Playback position"
324
+ aria-valuenow={scrubberPercent}
325
+ aria-valuemin={0}
326
+ aria-valuemax={100}
327
+ tabIndex={0}
328
+ ref={trackRef}
329
+ className="relative flex h-4 w-full cursor-pointer items-center"
330
+ onMouseDown={handleTrackPointerDown}
331
+ onTouchStart={handleTrackPointerDown}
332
+ onClick={(e) => e.stopPropagation()}
333
+ onMouseEnter={() => setScrubberHovered(true)}
334
+ onMouseLeave={() => setScrubberHovered(false)}
335
+ onKeyDown={(e) => {
336
+ if (e.key === 'ArrowRight') seekTo(Math.min(1, played + 0.05))
337
+ if (e.key === 'ArrowLeft') seekTo(Math.max(0, played - 0.05))
338
+ }}
339
+ >
340
+ <div
341
+ className={`w-full overflow-hidden rounded-full bg-white/30 transition-all duration-200 ${scrubberHovered || seeking ? 'h-1.5' : 'h-1'}`}
342
+ >
343
+ <div
344
+ className="h-full rounded-full bg-white"
345
+ style={{ width: `${scrubberPercent}%` }}
346
+ />
347
+ </div>
348
+ <div
349
+ className={`absolute size-3 -translate-x-1/2 rounded-full bg-white shadow transition-[opacity,transform] duration-200 ${scrubberHovered || seeking ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
350
+ style={{ left: `${scrubberPercent}%` }}
351
+ />
352
+ </div>
353
+ </div>
354
+ )}
355
+ </div>
356
+ )
357
+ }
358
+
359
+ export default MediaPlayer
@@ -0,0 +1,356 @@
1
+ import {
2
+ CheckCircleIcon,
3
+ DownloadSimpleIcon,
4
+ LockOpenIcon,
5
+ LockSimpleIcon,
6
+ } from '@phosphor-icons/react'
7
+ import React, { useEffect, useState } from 'react'
8
+
9
+ import { isDevBuild } from '../../../utils/isDevBuild'
10
+ import type {
11
+ LockedAttachmentBaseProps,
12
+ LockedAttachmentSource,
13
+ PaymentStatus,
14
+ } from '../types'
15
+ import { renderTypeIcon } from '../utils/icons'
16
+ import { getSourceType } from '../utils/mimeType'
17
+
18
+ import MediaPlayer from './MediaPlayer'
19
+
20
+ export interface VisitorCardProps extends LockedAttachmentBaseProps {
21
+ title?: string
22
+ /**
23
+ * Called when the visitor clicks Unlock. Return the resolved source URL.
24
+ * The component manages loading state and sets source internally on resolution.
25
+ * Omit to hide the Unlock button.
26
+ */
27
+ onUnlock?: () => Promise<LockedAttachmentSource>
28
+ /** Called when the visitor clicks Download on an unlocked card. */
29
+ onDownload?: () => void
30
+ }
31
+
32
+ const getLockIcon = (paymentStatus?: PaymentStatus): React.ElementType =>
33
+ paymentStatus === 'paid' ? LockOpenIcon : LockSimpleIcon
34
+
35
+ // ─── Shared primitives ────────────────────────────────────────────────────────
36
+
37
+ interface LockOverlayProps {
38
+ icon: React.ElementType
39
+ }
40
+
41
+ const LockOverlay: React.FC<LockOverlayProps> = (props) => {
42
+ const { icon: Icon } = props
43
+ return (
44
+ <div className="absolute inset-0 bg-black/30">
45
+ <div className="absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60">
46
+ <Icon className="size-4 text-white" weight="fill" />
47
+ </div>
48
+ </div>
49
+ )
50
+ }
51
+
52
+ interface LockedPreviewProps {
53
+ thumbnail?: string
54
+ mimeType: string
55
+ LockIcon: React.ElementType
56
+ }
57
+
58
+ const LockedPreview: React.FC<LockedPreviewProps> = (props) => {
59
+ const { thumbnail, mimeType, LockIcon } = props
60
+ return (
61
+ <div className="relative aspect-video overflow-hidden bg-black/5">
62
+ {thumbnail ? (
63
+ <img
64
+ src={thumbnail}
65
+ alt=""
66
+ className="absolute inset-0 h-full w-full object-cover"
67
+ />
68
+ ) : (
69
+ <div className="absolute inset-0 flex items-center justify-center">
70
+ {renderTypeIcon(mimeType, {
71
+ className: 'size-12 text-black/20',
72
+ weight: 'regular',
73
+ })}
74
+ </div>
75
+ )}
76
+ <LockOverlay icon={LockIcon} />
77
+ </div>
78
+ )
79
+ }
80
+
81
+ // ─── Per-type preview components ─────────────────────────────────────────────
82
+
83
+ interface ImagePreviewProps {
84
+ source?: string
85
+ thumbnail?: string
86
+ mimeType: string
87
+ title?: string
88
+ paymentStatus?: PaymentStatus
89
+ isLocked: boolean
90
+ }
91
+
92
+ const ImagePreview: React.FC<ImagePreviewProps> = (props) => {
93
+ const { source, thumbnail, mimeType, title, paymentStatus, isLocked } = props
94
+ const [sourceReady, setSourceReady] = useState(false)
95
+
96
+ useEffect(() => {
97
+ setSourceReady(false)
98
+ }, [source])
99
+
100
+ if (isLocked) {
101
+ return (
102
+ <LockedPreview
103
+ thumbnail={thumbnail}
104
+ mimeType={mimeType}
105
+ LockIcon={getLockIcon(paymentStatus)}
106
+ />
107
+ )
108
+ }
109
+
110
+ return (
111
+ <div className="relative overflow-hidden bg-black/5">
112
+ <img
113
+ src={source}
114
+ alt={title}
115
+ className={`block w-full transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
116
+ onLoad={() => setSourceReady(true)}
117
+ />
118
+ </div>
119
+ )
120
+ }
121
+
122
+ interface DocumentPreviewProps {
123
+ thumbnail?: string
124
+ mimeType: string
125
+ paymentStatus?: PaymentStatus
126
+ isLocked: boolean
127
+ }
128
+
129
+ const DocumentPreview: React.FC<DocumentPreviewProps> = (props) => {
130
+ const { thumbnail, mimeType, paymentStatus, isLocked } = props
131
+ return (
132
+ <div className="relative aspect-video overflow-hidden bg-black/5">
133
+ {thumbnail ? (
134
+ <img
135
+ src={thumbnail}
136
+ alt=""
137
+ className="absolute inset-0 h-full w-full object-cover"
138
+ />
139
+ ) : (
140
+ <div className="absolute inset-0 flex items-center justify-center">
141
+ {renderTypeIcon(mimeType, {
142
+ className: 'size-12 text-black/20',
143
+ weight: 'regular',
144
+ })}
145
+ </div>
146
+ )}
147
+ {isLocked && <LockOverlay icon={getLockIcon(paymentStatus)} />}
148
+ </div>
149
+ )
150
+ }
151
+
152
+ interface MediaPreviewProps {
153
+ source?: string
154
+ thumbnail?: string
155
+ mimeType: string
156
+ paymentStatus?: PaymentStatus
157
+ isLocked: boolean
158
+ }
159
+
160
+ const MediaPreview: React.FC<MediaPreviewProps> = (props) => {
161
+ const { source, thumbnail, mimeType, paymentStatus, isLocked } = props
162
+ if (isLocked) {
163
+ return (
164
+ <LockedPreview
165
+ thumbnail={thumbnail}
166
+ mimeType={mimeType}
167
+ LockIcon={getLockIcon(paymentStatus)}
168
+ />
169
+ )
170
+ }
171
+ return <MediaPlayer source={source!} mimeType={mimeType} poster={thumbnail} />
172
+ }
173
+
174
+ // ─── Actions ─────────────────────────────────────────────────────────────────
175
+
176
+ interface CardActionsProps {
177
+ isLocked: boolean
178
+ loading: boolean
179
+ paymentStatus?: PaymentStatus
180
+ source?: string
181
+ onUnlock?: () => void
182
+ onDownload?: () => void
183
+ }
184
+
185
+ const CardActions: React.FC<CardActionsProps> = (props) => {
186
+ const { isLocked, loading, paymentStatus, source, onUnlock, onDownload } =
187
+ props
188
+ const LockIcon = getLockIcon(paymentStatus)
189
+
190
+ if (isLocked && onUnlock) {
191
+ return (
192
+ <button
193
+ type="button"
194
+ onClick={onUnlock}
195
+ disabled={loading}
196
+ 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 disabled:opacity-70"
197
+ >
198
+ {loading ? (
199
+ <span className="flex items-center gap-1">
200
+ <span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.3s]" />
201
+ <span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.15s]" />
202
+ <span className="size-1 rounded-full bg-white animate-bounce" />
203
+ </span>
204
+ ) : (
205
+ <>
206
+ {paymentStatus === 'paid' ? (
207
+ <LockOpenIcon className="size-4" weight="fill" />
208
+ ) : (
209
+ <LockIcon className="size-4" weight="fill" />
210
+ )}
211
+ {paymentStatus === 'paid' ? 'Open' : 'Unlock'}
212
+ </>
213
+ )}
214
+ </button>
215
+ )
216
+ }
217
+
218
+ if (!isLocked && onDownload && source) {
219
+ return (
220
+ <a
221
+ href={source}
222
+ target="_blank"
223
+ rel="noopener noreferrer"
224
+ onClick={onDownload}
225
+ 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"
226
+ >
227
+ <DownloadSimpleIcon className="size-4" weight="bold" />
228
+ Download
229
+ </a>
230
+ )
231
+ }
232
+
233
+ return null
234
+ }
235
+
236
+ // ─── Card shell ───────────────────────────────────────────────────────────────
237
+
238
+ const VisitorCard: React.FC<VisitorCardProps> = (props) => {
239
+ const {
240
+ title,
241
+ amountText,
242
+ thumbnail,
243
+ source: sourceProp,
244
+ mimeType = 'application/octet-stream',
245
+ detail,
246
+ onUnlock,
247
+ onDownload,
248
+ paymentStatus,
249
+ } = props
250
+ const [source, setSource] = useState(sourceProp)
251
+ const [loading, setLoading] = useState(false)
252
+
253
+ useEffect(() => {
254
+ if (sourceProp !== undefined) setSource(sourceProp)
255
+ }, [sourceProp])
256
+
257
+ const isLocked = source === undefined
258
+ const sourceType = getSourceType(mimeType)
259
+
260
+ const handleUnlock = async () => {
261
+ if (!onUnlock) return
262
+ setLoading(true)
263
+ try {
264
+ const result = await onUnlock()
265
+ setSource(result.source)
266
+ } catch (err) {
267
+ if (isDevBuild()) console.debug('[LockedAttachment] onUnlock failed', err)
268
+ } finally {
269
+ setLoading(false)
270
+ }
271
+ }
272
+
273
+ let mediaPreview: React.ReactNode
274
+ if (sourceType === 'image') {
275
+ mediaPreview = (
276
+ <ImagePreview
277
+ source={source}
278
+ thumbnail={thumbnail}
279
+ mimeType={mimeType}
280
+ title={title}
281
+ paymentStatus={paymentStatus}
282
+ isLocked={isLocked}
283
+ />
284
+ )
285
+ } else if (sourceType === 'document') {
286
+ mediaPreview = (
287
+ <DocumentPreview
288
+ thumbnail={thumbnail}
289
+ mimeType={mimeType}
290
+ paymentStatus={paymentStatus}
291
+ isLocked={isLocked}
292
+ />
293
+ )
294
+ } else {
295
+ mediaPreview = (
296
+ <MediaPreview
297
+ source={source}
298
+ thumbnail={thumbnail}
299
+ mimeType={mimeType}
300
+ paymentStatus={paymentStatus}
301
+ isLocked={isLocked}
302
+ />
303
+ )
304
+ }
305
+
306
+ return (
307
+ <div className="w-[280px] select-none overflow-hidden rounded-3xl bg-white shadow-card">
308
+ {mediaPreview}
309
+ <div className="px-4 pb-3 pt-3">
310
+ <p className="mb-1.5 truncate text-base font-medium text-black">
311
+ {title}
312
+ </p>
313
+ <div className="flex items-center gap-1">
314
+ {renderTypeIcon(mimeType, {
315
+ className: 'size-5 shrink-0 text-black/55',
316
+ weight: 'regular',
317
+ })}
318
+ {detail && (
319
+ <span className="text-xs font-medium text-black/55">{detail}</span>
320
+ )}
321
+ {paymentStatus === 'paid' ? (
322
+ <>
323
+ <span className="text-xs font-medium text-black/55">•</span>
324
+ <span className="text-xs font-medium text-[#008236]">
325
+ Purchased
326
+ </span>
327
+ <CheckCircleIcon
328
+ className="size-4 text-[#008236]"
329
+ weight="bold"
330
+ />
331
+ </>
332
+ ) : (
333
+ amountText && (
334
+ <>
335
+ <span className="text-xs font-medium text-black/55">•</span>
336
+ <span className="text-xs font-medium text-black/55">
337
+ {amountText}
338
+ </span>
339
+ </>
340
+ )
341
+ )}
342
+ </div>
343
+ <CardActions
344
+ isLocked={isLocked}
345
+ loading={loading}
346
+ paymentStatus={paymentStatus}
347
+ source={source}
348
+ onUnlock={onUnlock ? handleUnlock : undefined}
349
+ onDownload={onDownload}
350
+ />
351
+ </div>
352
+ </div>
353
+ )
354
+ }
355
+
356
+ export default VisitorCard