@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.
- package/dist/Card-1CQEn-OT.js +171 -0
- package/dist/Card-1CQEn-OT.js.map +1 -0
- package/dist/Card-ClE_iExA.js +177 -0
- package/dist/Card-ClE_iExA.js.map +1 -0
- package/dist/{MediaPlayer-BCsdmsON.js → MediaPlayer-B9Ws2NeE.js} +115 -135
- package/dist/MediaPlayer-B9Ws2NeE.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/LockedAttachment/LockedAttachment.stories.tsx +136 -93
- package/src/components/LockedAttachment/components/Creator/Card.tsx +106 -106
- package/src/components/LockedAttachment/components/Creator/CardThumbnail.tsx +114 -0
- package/src/components/LockedAttachment/components/MediaPlayer.tsx +80 -66
- package/src/components/LockedAttachment/components/Visitor/Card.tsx +53 -78
- package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +3 -3
- package/src/components/LockedAttachment/components/Visitor/CardThumbnail.tsx +81 -0
- package/src/components/LockedAttachment/types.ts +2 -0
- package/dist/Card-C5t3dZ5q.js +0 -350
- package/dist/Card-C5t3dZ5q.js.map +0 -1
- package/dist/Card-Cn2va-Qr.js +0 -205
- package/dist/Card-Cn2va-Qr.js.map +0 -1
- package/dist/MediaPlayer-BCsdmsON.js.map +0 -1
- package/src/components/LockedAttachment/components/Creator/CardAudioPreview.tsx +0 -161
- package/src/components/LockedAttachment/components/Creator/CardCollapsedThumbnail.tsx +0 -58
- package/src/components/LockedAttachment/components/Creator/CardImagePreview.tsx +0 -56
- package/src/components/LockedAttachment/components/Creator/CardVideoPreview.tsx +0 -91
- package/src/components/LockedAttachment/components/Visitor/CardImagePreview.tsx +0 -39
- package/src/components/LockedAttachment/components/Visitor/CardMediaPreview.tsx +0 -36
- 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
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
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={() => {
|
|
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={() => {
|
|
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 (
|
|
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
|
|
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
|
-
|
|
51
|
+
|
|
52
|
+
const cardRef = useRef<HTMLDivElement>(null)
|
|
64
53
|
const fetchingRef = useRef(false)
|
|
65
|
-
|
|
54
|
+
|
|
66
55
|
const onFetchSourceRef = useRef(onFetchSource)
|
|
67
56
|
onFetchSourceRef.current = onFetchSource
|
|
68
57
|
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
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
|
-
}, [])
|
|
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,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
|
|
124
|
+
{detail && (
|
|
151
125
|
<span className="text-xs font-medium text-black/55">{detail}</span>
|
|
152
|
-
)
|
|
126
|
+
)}
|
|
127
|
+
|
|
153
128
|
{paymentStatus === 'paid' ? (
|
|
154
|
-
|
|
155
|
-
<span className="text-xs font-medium text-black/55"
|
|
129
|
+
<React.Fragment>
|
|
130
|
+
<span className="text-xs font-medium text-black/55">•</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"
|
|
140
|
+
<React.Fragment>
|
|
141
|
+
<span className="text-xs font-medium text-black/55">•</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={
|
|
177
|
-
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 }
|