@linktr.ee/messaging-react 1.32.0 → 1.32.1-rc-1777007852

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 (30) hide show
  1. package/dist/Card-1CQEn-OT.js +171 -0
  2. package/dist/Card-1CQEn-OT.js.map +1 -0
  3. package/dist/Card-ClE_iExA.js +177 -0
  4. package/dist/Card-ClE_iExA.js.map +1 -0
  5. package/dist/{MediaPlayer-BCsdmsON.js → MediaPlayer-B9Ws2NeE.js} +115 -135
  6. package/dist/MediaPlayer-B9Ws2NeE.js.map +1 -0
  7. package/dist/index.d.ts +3 -2
  8. package/dist/index.js +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +136 -93
  12. package/src/components/LockedAttachment/components/Creator/Card.tsx +106 -106
  13. package/src/components/LockedAttachment/components/Creator/CardThumbnail.tsx +114 -0
  14. package/src/components/LockedAttachment/components/MediaPlayer.tsx +80 -66
  15. package/src/components/LockedAttachment/components/Visitor/Card.tsx +53 -78
  16. package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +3 -3
  17. package/src/components/LockedAttachment/components/Visitor/CardThumbnail.tsx +81 -0
  18. package/src/components/LockedAttachment/types.ts +2 -0
  19. package/dist/Card-C5t3dZ5q.js +0 -350
  20. package/dist/Card-C5t3dZ5q.js.map +0 -1
  21. package/dist/Card-Cn2va-Qr.js +0 -205
  22. package/dist/Card-Cn2va-Qr.js.map +0 -1
  23. package/dist/MediaPlayer-BCsdmsON.js.map +0 -1
  24. package/src/components/LockedAttachment/components/Creator/CardAudioPreview.tsx +0 -161
  25. package/src/components/LockedAttachment/components/Creator/CardCollapsedThumbnail.tsx +0 -58
  26. package/src/components/LockedAttachment/components/Creator/CardImagePreview.tsx +0 -56
  27. package/src/components/LockedAttachment/components/Creator/CardVideoPreview.tsx +0 -91
  28. package/src/components/LockedAttachment/components/Visitor/CardImagePreview.tsx +0 -39
  29. package/src/components/LockedAttachment/components/Visitor/CardMediaPreview.tsx +0 -36
  30. package/src/components/LockedAttachment/components/Visitor/CardThumbnailPreview.tsx +0 -45
@@ -0,0 +1,114 @@
1
+ import classNames from 'classnames'
2
+ import React from 'react'
3
+
4
+ import { renderTypeIcon } from '../../utils/icons'
5
+ import { getSourceType } from '../../utils/mimeType'
6
+ import MediaPlayer from '../MediaPlayer'
7
+
8
+ interface CardThumbnailProps {
9
+ title?: string
10
+ sourceUrl?: string
11
+ thumbnailUrl?: string
12
+ mimeType: string
13
+ onToggle?: () => void
14
+ }
15
+
16
+ const CardThumbnail: React.FC<CardThumbnailProps> = ({
17
+ title,
18
+ sourceUrl,
19
+ thumbnailUrl,
20
+ mimeType,
21
+ onToggle,
22
+ }) => {
23
+ const isExpanded = onToggle && sourceUrl && thumbnailUrl
24
+
25
+ return (
26
+ <button
27
+ type="button"
28
+ disabled={!onToggle}
29
+ className={classNames(
30
+ 'relative block w-full overflow-hidden border-0 bg-black/5 p-0 text-left appearance-none',
31
+ { 'cursor-pointer': !!onToggle, 'cursor-default': !onToggle }
32
+ )}
33
+ onClick={onToggle}
34
+ aria-label={onToggle ? 'Toggle preview' : undefined}
35
+ >
36
+ {isExpanded ? (
37
+ <ThumbnailMedia
38
+ sourceUrl={sourceUrl}
39
+ thumbnailUrl={thumbnailUrl}
40
+ mimeType={mimeType}
41
+ />
42
+ ) : thumbnailUrl ? (
43
+ <div className="aspect-video overflow-hidden">
44
+ <img
45
+ src={thumbnailUrl}
46
+ alt={title}
47
+ draggable={false}
48
+ className="absolute inset-0 h-full w-full object-cover"
49
+ />
50
+ </div>
51
+ ) : (
52
+ <div className="aspect-video flex items-center justify-center">
53
+ {renderTypeIcon(mimeType, {
54
+ className: 'size-12 text-black/20',
55
+ weight: 'regular',
56
+ })}
57
+ </div>
58
+ )}
59
+
60
+ {!isExpanded && (
61
+ <div className="pointer-events-none absolute inset-0 bg-black/30" />
62
+ )}
63
+ </button>
64
+ )
65
+ }
66
+
67
+ interface ThumbnailMediaProps {
68
+ sourceUrl: string
69
+ thumbnailUrl: string
70
+ mimeType: string
71
+ }
72
+
73
+ const ThumbnailMedia: React.FC<ThumbnailMediaProps> = ({
74
+ sourceUrl,
75
+ thumbnailUrl,
76
+ mimeType,
77
+ }) => {
78
+ const sourceType = getSourceType(mimeType)
79
+
80
+ if (sourceType === 'video' || sourceType === 'audio') {
81
+ return (
82
+ <MediaPlayer
83
+ mimeType={mimeType}
84
+ source={sourceUrl}
85
+ poster={thumbnailUrl}
86
+ autoPlay={true}
87
+ loop={true}
88
+ controls={true}
89
+ muted={false}
90
+ />
91
+ )
92
+ }
93
+
94
+ if (sourceType === 'image') {
95
+ return (
96
+ <img src={sourceUrl} alt="" className="block w-full" draggable={false} />
97
+ )
98
+ }
99
+
100
+ if (sourceType === 'document') {
101
+ return (
102
+ <img
103
+ src={thumbnailUrl}
104
+ alt=""
105
+ className="block w-full"
106
+ draggable={false}
107
+ />
108
+ )
109
+ }
110
+
111
+ return null
112
+ }
113
+
114
+ export default CardThumbnail
@@ -3,13 +3,16 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
3
3
 
4
4
  import { isDevBuild } from '../../../utils/isDevBuild'
5
5
  import { renderTypeIcon } from '../utils/icons'
6
- import { getSourceType, type AttachmentSourceType } from '../utils/mimeType'
6
+ import { getSourceType } from '../utils/mimeType'
7
+
8
+ type TouchEventUnion =
9
+ | MouseEvent
10
+ | TouchEvent
11
+ | React.MouseEvent
12
+ | React.TouchEvent
7
13
 
8
- const getPlayerBg = (sourceType: AttachmentSourceType, poster?: string) => {
9
- return sourceType === 'audio' && !poster ? 'bg-black/5' : 'bg-black'
10
- }
11
14
 
12
- const getClientXFromEvent = ( e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent ): number => {
15
+ const getClientXFromEvent = (e: TouchEventUnion): number => {
13
16
  if ('touches' in e) {
14
17
  return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0
15
18
  }
@@ -26,7 +29,6 @@ export interface MediaPlayerProps {
26
29
  loop?: boolean
27
30
  controls?: boolean
28
31
  showProgress?: boolean
29
- onContainerClick?: () => void
30
32
  /** When true, requests muted playback (helps autoplay policies on video). */
31
33
  muted?: boolean
32
34
  }
@@ -40,14 +42,67 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
40
42
  loop = false,
41
43
  controls = true,
42
44
  showProgress = false,
43
- onContainerClick,
44
45
  muted = false,
45
46
  }) => {
47
+ // --- Derived ---
46
48
  const sourceType = getSourceType(mimeType)
49
+
50
+ // --- Refs ---
51
+ const playerRef = useRef<HTMLMediaElement>(null)
52
+ const trackRef = useRef<HTMLDivElement>(null)
53
+ const rafRef = useRef<number | null>(null)
54
+ const prevPlayingPropRef = useRef(playingProp)
55
+
56
+ // --- State: playback ---
47
57
  const [playing, setPlaying] = useState(autoPlay)
58
+ const [played, setPlayed] = useState(0)
59
+ const [seeking, setSeeking] = useState(false)
60
+
61
+ // --- State: UI ---
62
+ const [scrubberHovered, setScrubberHovered] = useState(false)
63
+ /** Set when autoplay/play() was rejected so user can start via gesture (no controls UI). */
64
+ const [manualPlayRequired, setManualPlayRequired] = useState(false)
65
+
66
+ // --- State: loading ---
67
+ const [buffering, setBuffering] = useState(false)
68
+ /** True until the first canPlay fires for the current source — hides controls/spinner behind poster. */
69
+ const [initialLoad, setInitialLoad] = useState(true)
70
+ const [videoAspect, setVideoAspect] = useState<number | null>(null)
71
+
72
+ // --- Callbacks ---
73
+ const startPlaybackFromGesture = useCallback(() => {
74
+ setManualPlayRequired(false)
75
+ setPlaying(true)
76
+ }, [])
77
+
78
+ const getFraction = useCallback((e: TouchEventUnion) => {
79
+ const track = trackRef.current
80
+ if (!track) return 0
81
+ const rect = track.getBoundingClientRect()
82
+ return Math.max(
83
+ 0,
84
+ Math.min(1, (getClientXFromEvent(e) - rect.left) / rect.width)
85
+ )
86
+ }, [])
87
+
88
+ const seekTo = useCallback((fraction: number) => {
89
+ const el = playerRef.current
90
+ if (el && el.duration) el.currentTime = fraction * el.duration
91
+ }, [])
92
+
93
+ const handleTrackPointerDown = (
94
+ e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
95
+ ) => {
96
+ e.stopPropagation()
97
+ setSeeking(true)
98
+ const fraction = getFraction(e)
99
+ setPlayed(fraction)
100
+ seekTo(fraction)
101
+ }
102
+
103
+ // --- Effects ---
48
104
 
49
105
  // Sync controlled playing prop to internal state
50
- const prevPlayingPropRef = useRef(playingProp)
51
106
  useEffect(() => {
52
107
  if (
53
108
  playingProp !== undefined &&
@@ -57,20 +112,8 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
57
112
  setPlaying(playingProp)
58
113
  }
59
114
  }, [playingProp])
60
- const [played, setPlayed] = useState(0)
61
- const [seeking, setSeeking] = useState(false)
62
- const [scrubberHovered, setScrubberHovered] = useState(false)
63
- const [videoAspect, setVideoAspect] = useState<number | null>(null)
64
- const [buffering, setBuffering] = useState(false)
65
- /** True until the first canPlay fires for the current source — hides controls/spinner behind poster. */
66
- const [initialLoad, setInitialLoad] = useState(true)
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<HTMLMediaElement>(null)
70
- const trackRef = useRef<HTMLDivElement>(null)
71
- const rafRef = useRef<number | null>(null)
72
-
73
115
 
116
+ // RAF-driven progress updates
74
117
  useEffect(() => {
75
118
  if (!playing) {
76
119
  if (rafRef.current !== null) {
@@ -108,39 +151,7 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
108
151
  }
109
152
  }, [playing])
110
153
 
111
- const startPlaybackFromGesture = useCallback(() => {
112
- setManualPlayRequired(false)
113
- setPlaying(true)
114
- }, [])
115
-
116
- const getFraction = useCallback(
117
- (e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
118
- const track = trackRef.current
119
- if (!track) return 0
120
- const rect = track.getBoundingClientRect()
121
- return Math.max(
122
- 0,
123
- Math.min(1, (getClientXFromEvent(e) - rect.left) / rect.width)
124
- )
125
- },
126
- []
127
- )
128
-
129
- const seekTo = useCallback((fraction: number) => {
130
- const el = playerRef.current
131
- if (el && el.duration) el.currentTime = fraction * el.duration
132
- }, [])
133
-
134
- const handleTrackPointerDown = (
135
- e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
136
- ) => {
137
- e.stopPropagation()
138
- setSeeking(true)
139
- const fraction = getFraction(e)
140
- setPlayed(fraction)
141
- seekTo(fraction)
142
- }
143
-
154
+ // Global seeking listeners
144
155
  useEffect(() => {
145
156
  if (!seeking) return
146
157
  const onMove = (e: MouseEvent | TouchEvent) => setPlayed(getFraction(e))
@@ -160,6 +171,7 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
160
171
  }
161
172
  }, [seeking, getFraction, seekTo])
162
173
 
174
+ // --- Derived render values ---
163
175
  // Use natural aspect ratio once metadata loads, fall back to 16:9 before then.
164
176
  const aspectStyle = videoAspect
165
177
  ? { aspectRatio: String(videoAspect) }
@@ -171,24 +183,16 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
171
183
  <div
172
184
  role="button"
173
185
  tabIndex={0}
174
- className={`relative cursor-pointer overflow-hidden ${getPlayerBg(sourceType, poster)}${aspectClass}`}
186
+ className={`relative cursor-pointer overflow-hidden bg-black ${aspectClass}`}
175
187
  style={aspectStyle}
176
188
  onClick={() => {
177
189
  if (manualPlayRequired) return
178
- if (onContainerClick) {
179
- onContainerClick()
180
- return
181
- }
182
190
  if (controls) setPlaying((p) => !p)
183
191
  }}
184
192
  onKeyDown={(e) => {
185
193
  if (e.key !== 'Enter' && e.key !== ' ') return
186
194
  e.preventDefault()
187
195
  if (manualPlayRequired) return
188
- if (onContainerClick) {
189
- onContainerClick()
190
- return
191
- }
192
196
  if (controls) setPlaying((p) => !p)
193
197
  }}
194
198
  >
@@ -217,7 +221,10 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
217
221
  muted={muted}
218
222
  style={{ width: '100%', height: '100%' }}
219
223
  onLoadStart={() => setBuffering(true)}
220
- onCanPlay={() => { setBuffering(false); setInitialLoad(false) }}
224
+ onCanPlay={() => {
225
+ setBuffering(false)
226
+ setInitialLoad(false)
227
+ }}
221
228
  onWaiting={() => setBuffering(true)}
222
229
  onPlay={() => setManualPlayRequired(false)}
223
230
  onEnded={() => {
@@ -238,12 +245,19 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
238
245
  playsInline
239
246
  style={{ width: '100%', height: '100%' }}
240
247
  onLoadStart={() => setBuffering(true)}
241
- onCanPlay={() => { setBuffering(false); setInitialLoad(false) }}
248
+ onCanPlay={() => {
249
+ setBuffering(false)
250
+ setInitialLoad(false)
251
+ }}
242
252
  onWaiting={() => setBuffering(true)}
243
253
  onPlay={() => setManualPlayRequired(false)}
244
254
  onLoadedMetadata={() => {
245
255
  const el = playerRef.current
246
- if (el instanceof HTMLVideoElement && el.videoWidth && el.videoHeight) {
256
+ if (
257
+ el instanceof HTMLVideoElement &&
258
+ el.videoWidth &&
259
+ el.videoHeight
260
+ ) {
247
261
  setVideoAspect(el.videoWidth / el.videoHeight)
248
262
  }
249
263
  }}
@@ -1,22 +1,14 @@
1
- import {
2
- CheckCircleIcon,
3
- LockOpenIcon,
4
- LockSimpleIcon,
5
- } from '@phosphor-icons/react'
1
+ import { CheckCircleIcon } from '@phosphor-icons/react'
6
2
  import React, { useCallback, useEffect, useRef, useState } from 'react'
7
3
 
8
4
  import type {
9
5
  LockedAttachmentBaseProps,
10
6
  LockedAttachmentSource,
11
- PaymentStatus,
12
7
  } from '../../types'
13
8
  import { renderTypeIcon } from '../../utils/icons'
14
- import { getSourceType } from '../../utils/mimeType'
15
9
 
16
10
  import CardActions from './CardActions'
17
- import ImagePreview from './CardImagePreview'
18
- import MediaPreview from './CardMediaPreview'
19
- import ThumbnailPreview from './CardThumbnailPreview'
11
+ import CardThumbnail from './CardThumbnail'
20
12
 
21
13
  export interface VisitorCardProps extends LockedAttachmentBaseProps {
22
14
  /**
@@ -43,10 +35,6 @@ export interface VisitorCardProps extends LockedAttachmentBaseProps {
43
35
  isUnlocking?: boolean
44
36
  }
45
37
 
46
- function getLockIcon(paymentStatus?: PaymentStatus): React.ElementType {
47
- return paymentStatus === 'paid' ? LockOpenIcon : LockSimpleIcon
48
- }
49
-
50
38
  const VisitorCard: React.FC<VisitorCardProps> = ({
51
39
  title,
52
40
  amountText,
@@ -60,15 +48,16 @@ const VisitorCard: React.FC<VisitorCardProps> = ({
60
48
  isUnlocking = false,
61
49
  }) => {
62
50
  const [source, setSource] = useState<LockedAttachmentSource | undefined>()
63
- const hasMounted = useRef(false)
51
+
52
+ const cardRef = useRef<HTMLDivElement>(null)
64
53
  const fetchingRef = useRef(false)
65
- // Stable ref so fetchSource doesn't change identity when onFetchSource prop changes
54
+
66
55
  const onFetchSourceRef = useRef(onFetchSource)
67
56
  onFetchSourceRef.current = onFetchSource
68
57
 
69
- const isLocked = source === undefined
70
- const sourceType = getSourceType(mimeType)
71
- const LockIcon = isLocked ? getLockIcon(paymentStatus) : undefined
58
+ const effectiveSourceUrl = source?.sourceUrl
59
+ const effectiveThumbnail = source?.thumbnailUrl ?? thumbnailUrl
60
+ const effectiveRedeemUrl = source?.redeemUrl
72
61
 
73
62
  const fetchSource = useCallback(async (): Promise<void> => {
74
63
  if (fetchingRef.current) return
@@ -79,20 +68,7 @@ const VisitorCard: React.FC<VisitorCardProps> = ({
79
68
  } finally {
80
69
  fetchingRef.current = false
81
70
  }
82
- }, []) // stable — reads onFetchSource via ref
83
-
84
- // When paymentStatus transitions to 'paid' (e.g. after checkout completes),
85
- // automatically fetch the source. Skipped on mount.
86
- useEffect(() => {
87
- if (!hasMounted.current) {
88
- hasMounted.current = true
89
- return
90
- }
91
-
92
- if (paymentStatus === 'paid') {
93
- void fetchSource()
94
- }
95
- }, [paymentStatus, fetchSource])
71
+ }, [])
96
72
 
97
73
  const handleUnlockClick = useCallback(() => {
98
74
  if (paymentStatus === 'paid') {
@@ -100,44 +76,41 @@ const VisitorCard: React.FC<VisitorCardProps> = ({
100
76
  } else {
101
77
  onUnlockClick?.()
102
78
  }
103
- }, [paymentStatus, onUnlockClick, fetchSource])
104
-
105
- let mediaPreview: React.ReactNode
106
- if (sourceType === 'image') {
107
- mediaPreview = (
108
- <ImagePreview
109
- key={source?.sourceUrl}
110
- sourceUrl={source?.sourceUrl}
111
- thumbnailUrl={thumbnailUrl}
112
- mimeType={mimeType}
113
- title={title}
114
- LockIcon={LockIcon}
115
- />
116
- )
117
- } else if (sourceType === 'document') {
118
- mediaPreview = (
119
- <ThumbnailPreview
120
- key={source?.sourceUrl}
121
- thumbnailUrl={thumbnailUrl}
122
- mimeType={mimeType}
123
- LockIcon={LockIcon}
124
- />
79
+ }, [paymentStatus, fetchSource, onUnlockClick])
80
+
81
+ // Fetch source when card is in viewport
82
+ useEffect(() => {
83
+ if (!cardRef.current) return
84
+ if (paymentStatus !== 'paid' || source !== undefined) return
85
+
86
+ const observer = new IntersectionObserver(
87
+ ([entry]) => {
88
+ if (entry.isIntersecting) {
89
+ void fetchSource()
90
+ observer.disconnect()
91
+ }
92
+ },
93
+ { threshold: 1.0 }
125
94
  )
126
- } else {
127
- mediaPreview = (
128
- <MediaPreview
129
- key={source?.sourceUrl}
130
- sourceUrl={source?.sourceUrl}
131
- thumbnailUrl={thumbnailUrl}
95
+
96
+ observer.observe(cardRef.current)
97
+ return () => observer.disconnect()
98
+ }, [paymentStatus, source, fetchSource])
99
+
100
+ return (
101
+ <div
102
+ ref={cardRef}
103
+ data-testid="locked-attachment"
104
+ 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)]"
105
+ >
106
+ <CardThumbnail
107
+ title={title}
108
+ sourceUrl={effectiveSourceUrl}
109
+ thumbnailUrl={effectiveThumbnail}
132
110
  mimeType={mimeType}
133
- LockIcon={LockIcon}
111
+ paymentStatus={paymentStatus}
134
112
  />
135
- )
136
- }
137
113
 
138
- return (
139
- <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)]">
140
- {mediaPreview}
141
114
  <div className="px-4 pb-3 pt-3">
142
115
  <p className="mb-1.5 truncate text-base font-medium text-black">
143
116
  {title}
@@ -147,12 +120,14 @@ const VisitorCard: React.FC<VisitorCardProps> = ({
147
120
  className: 'size-5 shrink-0 text-black/55',
148
121
  weight: 'regular',
149
122
  })}
150
- {detail != null ? (
123
+
124
+ {detail && (
151
125
  <span className="text-xs font-medium text-black/55">{detail}</span>
152
- ) : null}
126
+ )}
127
+
153
128
  {paymentStatus === 'paid' ? (
154
- <>
155
- <span className="text-xs font-medium text-black/55">•</span>
129
+ <React.Fragment>
130
+ <span className="text-xs font-medium text-black/55">&bull;</span>
156
131
  <span className="text-xs font-medium text-[#008236]">
157
132
  Purchased
158
133
  </span>
@@ -160,21 +135,21 @@ const VisitorCard: React.FC<VisitorCardProps> = ({
160
135
  className="size-4 text-[#008236]"
161
136
  weight="bold"
162
137
  />
163
- </>
138
+ </React.Fragment>
164
139
  ) : amountText != null ? (
165
- <>
166
- <span className="text-xs font-medium text-black/55">•</span>
140
+ <React.Fragment>
141
+ <span className="text-xs font-medium text-black/55">&bull;</span>
167
142
  <span className="text-xs font-medium text-black/55">
168
143
  {amountText}
169
144
  </span>
170
- </>
145
+ </React.Fragment>
171
146
  ) : null}
172
147
  </div>
148
+
173
149
  <CardActions
174
- isLocked={isLocked}
175
150
  isUnlocking={isUnlocking}
176
- sourceUrl={source?.sourceUrl}
177
- redeemUrl={source?.redeemUrl}
151
+ sourceUrl={effectiveSourceUrl}
152
+ redeemUrl={effectiveRedeemUrl}
178
153
  onUnlockClicked={handleUnlockClick}
179
154
  onDownloadClicked={onDownloadClick}
180
155
  />
@@ -2,17 +2,15 @@ import { DownloadSimpleIcon, LockSimpleIcon } from '@phosphor-icons/react'
2
2
  import React from 'react'
3
3
 
4
4
  interface CardActionsProps {
5
- isLocked: boolean
6
- isUnlocking?: boolean
7
5
  sourceUrl?: string
8
6
  redeemUrl?: string
9
7
  onUnlockClicked?: () => void
10
8
  onDownloadClicked?: () => void
9
+ isUnlocking?: boolean
11
10
  }
12
11
 
13
12
  const CardActions: React.FC<CardActionsProps> = (props) => {
14
13
  const {
15
- isLocked,
16
14
  isUnlocking = false,
17
15
  sourceUrl,
18
16
  redeemUrl,
@@ -20,6 +18,8 @@ const CardActions: React.FC<CardActionsProps> = (props) => {
20
18
  onDownloadClicked,
21
19
  } = props
22
20
 
21
+ const isLocked = sourceUrl === undefined
22
+
23
23
  if (isLocked && onUnlockClicked != null) {
24
24
  return (
25
25
  <button
@@ -0,0 +1,81 @@
1
+ import { LockOpenIcon, LockSimpleIcon } from '@phosphor-icons/react'
2
+ import React, { useState } from 'react'
3
+
4
+ import { PaymentStatus } from '../../types'
5
+ import { renderTypeIcon } from '../../utils/icons'
6
+ import { getSourceType } from '../../utils/mimeType'
7
+ import MediaPlayer from '../MediaPlayer'
8
+
9
+ interface CardThumbnailProps {
10
+ title?: string
11
+ sourceUrl?: string
12
+ thumbnailUrl?: string
13
+ mimeType: string
14
+ paymentStatus?: PaymentStatus
15
+ }
16
+
17
+ const CardThumbnail: React.FC<CardThumbnailProps> = ({
18
+ title,
19
+ sourceUrl,
20
+ thumbnailUrl,
21
+ mimeType,
22
+ paymentStatus,
23
+ }) => {
24
+ const [sourceReady, setSourceReady] = useState(false)
25
+
26
+ const isLocked = sourceUrl === undefined
27
+ const sourceType = getSourceType(mimeType)
28
+
29
+ if (!isLocked) {
30
+ if (sourceType === 'audio' || sourceType === 'video') {
31
+ return (
32
+ <MediaPlayer
33
+ source={sourceUrl}
34
+ poster={thumbnailUrl}
35
+ mimeType={mimeType}
36
+ />
37
+ )
38
+ }
39
+
40
+ return (
41
+ <div className="relative overflow-hidden bg-black/5">
42
+ <img
43
+ src={sourceType === 'document' ? thumbnailUrl : sourceUrl}
44
+ alt={title}
45
+ className={`block w-full transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
46
+ draggable={false}
47
+ onLoad={() => setSourceReady(true)}
48
+ />
49
+ </div>
50
+ )
51
+ }
52
+
53
+ return (
54
+ <div className="relative aspect-video overflow-hidden bg-black/5">
55
+ {thumbnailUrl != null ? (
56
+ <img
57
+ src={thumbnailUrl}
58
+ alt=""
59
+ className="absolute inset-0 h-full w-full object-cover"
60
+ draggable={false}
61
+ />
62
+ ) : (
63
+ <div className="absolute inset-0 flex items-center justify-center">
64
+ {renderTypeIcon(mimeType, {
65
+ className: 'size-12 text-black/20',
66
+ weight: 'regular',
67
+ })}
68
+ </div>
69
+ )}
70
+ {isLocked && (
71
+ <div className="absolute inset-0 bg-black/30">
72
+ <div className="absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60 text-white">
73
+ {paymentStatus === 'paid' ? <LockOpenIcon /> : <LockSimpleIcon />}
74
+ </div>
75
+ </div>
76
+ )}
77
+ </div>
78
+ )
79
+ }
80
+
81
+ export default CardThumbnail
@@ -16,6 +16,8 @@ export interface LockedAttachmentSource {
16
16
  sourceUrl: string
17
17
  /** URL opened when the visitor clicks Download — may be a file or a web destination. */
18
18
  redeemUrl?: string
19
+ /** Thumbnail URL from the fetched asset — overrides the metadata thumbnail when present. */
20
+ thumbnailUrl?: string
19
21
  }
20
22
 
21
23
  export type { PaymentStatus }