@linktr.ee/messaging-react 1.31.0-rc-1776677746 → 1.31.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/{Creator-DGe3CQ_j.js → Card-C5t3dZ5q.js} +177 -150
- package/dist/Card-C5t3dZ5q.js.map +1 -0
- package/dist/Card-Cn2va-Qr.js +205 -0
- package/dist/Card-Cn2va-Qr.js.map +1 -0
- package/dist/index.d.ts +35 -30
- package/dist/index.js +951 -956
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ChannelView.tsx +24 -36
- package/src/components/CustomMessage/CustomMessage.stories.tsx +1 -14
- package/src/components/CustomMessage/context.tsx +20 -0
- package/src/components/CustomMessage/index.tsx +39 -28
- package/src/components/LockedAttachment/LockedAttachment.stories.tsx +8 -13
- package/src/components/LockedAttachment/components/Creator/Card.tsx +159 -0
- package/src/components/LockedAttachment/components/Creator/CardAudioPreview.tsx +161 -0
- package/src/components/LockedAttachment/components/Creator/CardCollapsedThumbnail.tsx +58 -0
- package/src/components/LockedAttachment/components/Creator/CardImagePreview.tsx +56 -0
- package/src/components/LockedAttachment/components/Creator/CardVideoPreview.tsx +91 -0
- package/src/components/LockedAttachment/components/Creator/index.tsx +2 -0
- package/src/components/LockedAttachment/components/Visitor/Card.tsx +186 -0
- package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +71 -0
- package/src/components/LockedAttachment/components/Visitor/CardImagePreview.tsx +39 -0
- package/src/components/LockedAttachment/components/Visitor/CardMediaPreview.tsx +36 -0
- package/src/components/LockedAttachment/components/Visitor/CardThumbnailPreview.tsx +45 -0
- package/src/components/LockedAttachment/components/Visitor/index.ts +2 -0
- package/src/components/LockedAttachment/index.tsx +16 -23
- package/src/components/LockedAttachment/types.ts +14 -1
- package/src/components/MessagingShell/index.tsx +0 -6
- package/src/index.ts +4 -1
- package/src/types.ts +0 -21
- package/dist/Creator-DGe3CQ_j.js.map +0 -1
- package/dist/Visitor-DyJTWB2_.js +0 -204
- package/dist/Visitor-DyJTWB2_.js.map +0 -1
- package/src/components/LockedAttachment/components/Creator.tsx +0 -470
- package/src/components/LockedAttachment/components/Visitor.tsx +0 -356
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { PauseIcon, PlayIcon } from '@phosphor-icons/react'
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import CollapsedThumbnail from './CardCollapsedThumbnail'
|
|
5
|
+
|
|
6
|
+
interface AudioPreviewProps {
|
|
7
|
+
sourceUrl?: string
|
|
8
|
+
thumbnailUrl?: string
|
|
9
|
+
mimeType: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const AudioPreview: React.FC<AudioPreviewProps> = (props) => {
|
|
13
|
+
const { sourceUrl, thumbnailUrl, mimeType } = props
|
|
14
|
+
const [playing, setPlaying] = useState(false)
|
|
15
|
+
const [played, setPlayed] = useState(0)
|
|
16
|
+
const [seeking, setSeeking] = useState(false)
|
|
17
|
+
const audioRef = useRef<HTMLAudioElement>(null)
|
|
18
|
+
const trackRef = useRef<HTMLDivElement>(null)
|
|
19
|
+
const rafRef = useRef<number | null>(null)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const el = audioRef.current
|
|
23
|
+
if (!el) return
|
|
24
|
+
if (playing) {
|
|
25
|
+
void el.play().catch(() => setPlaying(false))
|
|
26
|
+
} else {
|
|
27
|
+
el.pause()
|
|
28
|
+
}
|
|
29
|
+
}, [playing])
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!playing) {
|
|
33
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
const tick = () => {
|
|
37
|
+
const el = audioRef.current
|
|
38
|
+
if (el && el.duration && !seeking) setPlayed(el.currentTime / el.duration)
|
|
39
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
40
|
+
}
|
|
41
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
42
|
+
return () => {
|
|
43
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
|
44
|
+
}
|
|
45
|
+
}, [playing, seeking])
|
|
46
|
+
|
|
47
|
+
const [audioReady, setAudioReady] = useState(false)
|
|
48
|
+
|
|
49
|
+
const getFraction = useCallback(
|
|
50
|
+
(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
|
|
51
|
+
const track = trackRef.current
|
|
52
|
+
if (!track) return 0
|
|
53
|
+
const clientX =
|
|
54
|
+
'touches' in e
|
|
55
|
+
? (e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0)
|
|
56
|
+
: e.clientX
|
|
57
|
+
const rect = track.getBoundingClientRect()
|
|
58
|
+
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
|
59
|
+
},
|
|
60
|
+
[]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const seekTo = useCallback((fraction: number) => {
|
|
64
|
+
const el = audioRef.current
|
|
65
|
+
if (el && el.duration) el.currentTime = fraction * el.duration
|
|
66
|
+
}, [])
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!seeking) return
|
|
70
|
+
const onMove = (e: MouseEvent | TouchEvent) => {
|
|
71
|
+
const f = getFraction(e)
|
|
72
|
+
setPlayed(f)
|
|
73
|
+
seekTo(f)
|
|
74
|
+
}
|
|
75
|
+
const onUp = (e: MouseEvent | TouchEvent) => {
|
|
76
|
+
setSeeking(false)
|
|
77
|
+
seekTo(getFraction(e))
|
|
78
|
+
}
|
|
79
|
+
window.addEventListener('mousemove', onMove)
|
|
80
|
+
window.addEventListener('mouseup', onUp)
|
|
81
|
+
window.addEventListener('touchmove', onMove, { passive: true })
|
|
82
|
+
window.addEventListener('touchend', onUp)
|
|
83
|
+
return () => {
|
|
84
|
+
window.removeEventListener('mousemove', onMove)
|
|
85
|
+
window.removeEventListener('mouseup', onUp)
|
|
86
|
+
window.removeEventListener('touchmove', onMove)
|
|
87
|
+
window.removeEventListener('touchend', onUp)
|
|
88
|
+
}
|
|
89
|
+
}, [seeking, getFraction, seekTo])
|
|
90
|
+
|
|
91
|
+
const toggle = useCallback(() => setPlaying((p) => !p), [])
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="relative">
|
|
95
|
+
{sourceUrl && (
|
|
96
|
+
<audio
|
|
97
|
+
ref={audioRef}
|
|
98
|
+
src={sourceUrl}
|
|
99
|
+
loop
|
|
100
|
+
onCanPlay={() => setAudioReady(true)}
|
|
101
|
+
onEnded={() => {
|
|
102
|
+
setPlaying(false)
|
|
103
|
+
setPlayed(0)
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<track kind="captions" />
|
|
107
|
+
</audio>
|
|
108
|
+
)}
|
|
109
|
+
<CollapsedThumbnail
|
|
110
|
+
thumbnailUrl={thumbnailUrl}
|
|
111
|
+
mimeType={mimeType}
|
|
112
|
+
overlayIcon={
|
|
113
|
+
sourceUrl && audioReady ? (playing ? PauseIcon : PlayIcon) : undefined
|
|
114
|
+
}
|
|
115
|
+
onClick={sourceUrl && audioReady ? toggle : undefined}
|
|
116
|
+
/>
|
|
117
|
+
{sourceUrl && audioReady && (
|
|
118
|
+
<div className="absolute inset-x-0 bottom-0 px-3 pb-2.5 pt-6 bg-gradient-to-t from-black/40 to-transparent">
|
|
119
|
+
<div
|
|
120
|
+
ref={trackRef}
|
|
121
|
+
role="slider"
|
|
122
|
+
aria-label="Playback position"
|
|
123
|
+
aria-valuenow={Math.round(played * 100)}
|
|
124
|
+
aria-valuemin={0}
|
|
125
|
+
aria-valuemax={100}
|
|
126
|
+
tabIndex={0}
|
|
127
|
+
className="relative flex h-4 w-full cursor-pointer items-center"
|
|
128
|
+
onMouseDown={(e) => {
|
|
129
|
+
e.stopPropagation()
|
|
130
|
+
setSeeking(true)
|
|
131
|
+
const f = getFraction(e)
|
|
132
|
+
setPlayed(f)
|
|
133
|
+
seekTo(f)
|
|
134
|
+
}}
|
|
135
|
+
onTouchStart={(e) => {
|
|
136
|
+
e.stopPropagation()
|
|
137
|
+
setSeeking(true)
|
|
138
|
+
const f = getFraction(e)
|
|
139
|
+
setPlayed(f)
|
|
140
|
+
seekTo(f)
|
|
141
|
+
}}
|
|
142
|
+
onClick={(e) => e.stopPropagation()}
|
|
143
|
+
onKeyDown={(e) => {
|
|
144
|
+
if (e.key === 'ArrowRight') seekTo(Math.min(1, played + 0.05))
|
|
145
|
+
if (e.key === 'ArrowLeft') seekTo(Math.max(0, played - 0.05))
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<div className="w-full overflow-hidden rounded-full bg-white/30 h-1">
|
|
149
|
+
<div
|
|
150
|
+
className="h-full rounded-full bg-white"
|
|
151
|
+
style={{ width: `${Math.round(played * 100)}%` }}
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export default AudioPreview
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import classNames from 'classnames'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import { renderTypeIcon } from '../../utils/icons'
|
|
5
|
+
|
|
6
|
+
interface CollapsedThumbnailProps {
|
|
7
|
+
thumbnailUrl?: string
|
|
8
|
+
mimeType: string
|
|
9
|
+
overlayIcon?: React.ElementType
|
|
10
|
+
darkOverlay?: boolean
|
|
11
|
+
onClick?: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const CollapsedThumbnail: React.FC<CollapsedThumbnailProps> = ({
|
|
15
|
+
thumbnailUrl,
|
|
16
|
+
mimeType,
|
|
17
|
+
overlayIcon: OverlayIcon,
|
|
18
|
+
darkOverlay,
|
|
19
|
+
onClick,
|
|
20
|
+
}) => {
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
disabled={!onClick}
|
|
25
|
+
className={classNames(
|
|
26
|
+
'relative aspect-video block w-full overflow-hidden border-0 bg-black/5 p-0 text-left appearance-none',
|
|
27
|
+
{ 'cursor-pointer': !!onClick, 'cursor-default': !onClick }
|
|
28
|
+
)}
|
|
29
|
+
onClick={onClick}
|
|
30
|
+
aria-label={OverlayIcon ? 'Toggle preview' : undefined}
|
|
31
|
+
>
|
|
32
|
+
{thumbnailUrl ? (
|
|
33
|
+
<img
|
|
34
|
+
src={thumbnailUrl}
|
|
35
|
+
alt=""
|
|
36
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
37
|
+
/>
|
|
38
|
+
) : (
|
|
39
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
40
|
+
{renderTypeIcon(mimeType, {
|
|
41
|
+
className: 'size-12 text-black/20',
|
|
42
|
+
weight: 'regular',
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
{darkOverlay && (
|
|
47
|
+
<div className="pointer-events-none absolute inset-0 bg-black/30" />
|
|
48
|
+
)}
|
|
49
|
+
{OverlayIcon && (
|
|
50
|
+
<div className="pointer-events-none absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60 text-white">
|
|
51
|
+
<OverlayIcon className="size-4" weight="fill" />
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</button>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default CollapsedThumbnail
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { EyeIcon, EyeSlashIcon } from '@phosphor-icons/react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import CollapsedThumbnail from './CardCollapsedThumbnail'
|
|
5
|
+
|
|
6
|
+
interface ImagePreviewProps {
|
|
7
|
+
sourceUrl?: string
|
|
8
|
+
thumbnailUrl?: string
|
|
9
|
+
mimeType: string
|
|
10
|
+
title?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ImagePreview: React.FC<ImagePreviewProps> = (props) => {
|
|
14
|
+
const { sourceUrl, thumbnailUrl, mimeType, title } = props
|
|
15
|
+
const [expanded, setExpanded] = useState(false)
|
|
16
|
+
|
|
17
|
+
if (expanded && sourceUrl) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="relative">
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
className="block w-full cursor-pointer border-0 p-0 text-left appearance-none"
|
|
23
|
+
onClick={() => setExpanded(false)}
|
|
24
|
+
aria-label="Close preview"
|
|
25
|
+
>
|
|
26
|
+
<img src={sourceUrl} alt={title ?? ''} className="block w-full" />
|
|
27
|
+
</button>
|
|
28
|
+
<CloseButton onClose={() => setExpanded(false)} />
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<CollapsedThumbnail
|
|
35
|
+
thumbnailUrl={thumbnailUrl}
|
|
36
|
+
mimeType={mimeType}
|
|
37
|
+
overlayIcon={sourceUrl ? EyeSlashIcon : undefined}
|
|
38
|
+
onClick={sourceUrl ? () => setExpanded(true) : undefined}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CloseButton: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
|
44
|
+
return (
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={onClose}
|
|
48
|
+
className="absolute left-3 top-3 z-40 flex size-8 items-center justify-center rounded-full bg-black/60 text-white"
|
|
49
|
+
aria-label="Close preview"
|
|
50
|
+
>
|
|
51
|
+
<EyeIcon className="size-4" weight="fill" />
|
|
52
|
+
</button>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default ImagePreview
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { EyeIcon, EyeSlashIcon } from '@phosphor-icons/react'
|
|
2
|
+
import classNames from 'classnames'
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { renderTypeIcon } from '../../utils/icons'
|
|
6
|
+
import MediaPlayer from '../MediaPlayer'
|
|
7
|
+
|
|
8
|
+
import CollapsedThumbnail from './CardCollapsedThumbnail'
|
|
9
|
+
|
|
10
|
+
interface VideoPreviewProps {
|
|
11
|
+
sourceUrl?: string
|
|
12
|
+
thumbnailUrl?: string
|
|
13
|
+
mimeType: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const VideoPreview: React.FC<VideoPreviewProps> = (props) => {
|
|
17
|
+
const { sourceUrl, thumbnailUrl, mimeType } = props
|
|
18
|
+
const [expanded, setExpanded] = useState(false)
|
|
19
|
+
|
|
20
|
+
const collapse = () => {
|
|
21
|
+
setExpanded(false)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!sourceUrl) {
|
|
25
|
+
return (
|
|
26
|
+
<CollapsedThumbnail thumbnailUrl={thumbnailUrl} mimeType={mimeType} />
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className={classNames('relative overflow-hidden', {
|
|
33
|
+
'aspect-video': !expanded,
|
|
34
|
+
})}
|
|
35
|
+
>
|
|
36
|
+
<MediaPlayer
|
|
37
|
+
source={sourceUrl}
|
|
38
|
+
mimeType={mimeType}
|
|
39
|
+
poster={thumbnailUrl}
|
|
40
|
+
playing={expanded}
|
|
41
|
+
loop={true}
|
|
42
|
+
controls={false}
|
|
43
|
+
muted={true}
|
|
44
|
+
showProgress={true}
|
|
45
|
+
onContainerClick={collapse}
|
|
46
|
+
/>
|
|
47
|
+
{!expanded && (
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
className="absolute inset-0 block cursor-pointer border-0 p-0 text-left appearance-none"
|
|
51
|
+
onClick={() => setExpanded(true)}
|
|
52
|
+
aria-label="Expand video preview"
|
|
53
|
+
>
|
|
54
|
+
{thumbnailUrl ? (
|
|
55
|
+
<img
|
|
56
|
+
src={thumbnailUrl}
|
|
57
|
+
alt=""
|
|
58
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
59
|
+
/>
|
|
60
|
+
) : (
|
|
61
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
62
|
+
{renderTypeIcon(mimeType, {
|
|
63
|
+
className: 'size-12 text-black/20',
|
|
64
|
+
weight: 'regular',
|
|
65
|
+
})}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
<div className="pointer-events-none absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60 text-white">
|
|
69
|
+
<EyeSlashIcon className="size-4" weight="fill" />
|
|
70
|
+
</div>
|
|
71
|
+
</button>
|
|
72
|
+
)}
|
|
73
|
+
{expanded && <CloseButton onClose={collapse} />}
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const CloseButton: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
|
79
|
+
return (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={onClose}
|
|
83
|
+
className="absolute left-3 top-3 z-40 flex size-8 items-center justify-center rounded-full bg-black/60 text-white"
|
|
84
|
+
aria-label="Close preview"
|
|
85
|
+
>
|
|
86
|
+
<EyeIcon className="size-4" weight="fill" />
|
|
87
|
+
</button>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export default VideoPreview
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CheckCircleIcon,
|
|
3
|
+
LockOpenIcon,
|
|
4
|
+
LockSimpleIcon,
|
|
5
|
+
} from '@phosphor-icons/react'
|
|
6
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
LockedAttachmentBaseProps,
|
|
10
|
+
LockedAttachmentSource,
|
|
11
|
+
PaymentStatus,
|
|
12
|
+
} from '../../types'
|
|
13
|
+
import { renderTypeIcon } from '../../utils/icons'
|
|
14
|
+
import { getSourceType } from '../../utils/mimeType'
|
|
15
|
+
|
|
16
|
+
import CardActions from './CardActions'
|
|
17
|
+
import ImagePreview from './CardImagePreview'
|
|
18
|
+
import MediaPreview from './CardMediaPreview'
|
|
19
|
+
import ThumbnailPreview from './CardThumbnailPreview'
|
|
20
|
+
|
|
21
|
+
export interface VisitorCardProps extends LockedAttachmentBaseProps {
|
|
22
|
+
/**
|
|
23
|
+
* Called when the visitor clicks Unlock on an unpaid attachment.
|
|
24
|
+
* Use this to open a checkout flow. Omit to hide the Unlock button.
|
|
25
|
+
*/
|
|
26
|
+
onUnlockClick?: () => void
|
|
27
|
+
/**
|
|
28
|
+
* Called to fetch the attachment source — fired automatically when
|
|
29
|
+
* paymentStatus transitions to 'paid', or immediately on click when
|
|
30
|
+
* paymentStatus is already 'paid'. Return a LockedAttachmentSource to
|
|
31
|
+
* unlock the card.
|
|
32
|
+
*/
|
|
33
|
+
onFetchSource?: () => Promise<LockedAttachmentSource | void>
|
|
34
|
+
/**
|
|
35
|
+
* Called when the visitor clicks Download on an unlocked card.
|
|
36
|
+
* Omit to hide the Download button.
|
|
37
|
+
*/
|
|
38
|
+
onDownloadClick?: () => void
|
|
39
|
+
/**
|
|
40
|
+
* When true, shows loading dots on the Unlock button.
|
|
41
|
+
* Driven by the LockedAttachmentContext (e.g. checkout in progress, payment processing).
|
|
42
|
+
*/
|
|
43
|
+
isUnlocking?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getLockIcon(paymentStatus?: PaymentStatus): React.ElementType {
|
|
47
|
+
return paymentStatus === 'paid' ? LockOpenIcon : LockSimpleIcon
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const VisitorCard: React.FC<VisitorCardProps> = ({
|
|
51
|
+
title,
|
|
52
|
+
amountText,
|
|
53
|
+
thumbnailUrl,
|
|
54
|
+
mimeType = 'application/octet-stream',
|
|
55
|
+
detail,
|
|
56
|
+
onUnlockClick,
|
|
57
|
+
onFetchSource,
|
|
58
|
+
onDownloadClick,
|
|
59
|
+
paymentStatus,
|
|
60
|
+
isUnlocking = false,
|
|
61
|
+
}) => {
|
|
62
|
+
const [source, setSource] = useState<LockedAttachmentSource | undefined>()
|
|
63
|
+
const hasMounted = useRef(false)
|
|
64
|
+
const fetchingRef = useRef(false)
|
|
65
|
+
// Stable ref so fetchSource doesn't change identity when onFetchSource prop changes
|
|
66
|
+
const onFetchSourceRef = useRef(onFetchSource)
|
|
67
|
+
onFetchSourceRef.current = onFetchSource
|
|
68
|
+
|
|
69
|
+
const isLocked = source === undefined
|
|
70
|
+
const sourceType = getSourceType(mimeType)
|
|
71
|
+
const LockIcon = isLocked ? getLockIcon(paymentStatus) : undefined
|
|
72
|
+
|
|
73
|
+
const fetchSource = useCallback(async (): Promise<void> => {
|
|
74
|
+
if (fetchingRef.current) return
|
|
75
|
+
fetchingRef.current = true
|
|
76
|
+
try {
|
|
77
|
+
const result = await onFetchSourceRef.current?.()
|
|
78
|
+
if (result) setSource(result)
|
|
79
|
+
} finally {
|
|
80
|
+
fetchingRef.current = false
|
|
81
|
+
}
|
|
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])
|
|
96
|
+
|
|
97
|
+
const handleUnlockClick = useCallback(() => {
|
|
98
|
+
if (paymentStatus === 'paid') {
|
|
99
|
+
void fetchSource()
|
|
100
|
+
} else {
|
|
101
|
+
onUnlockClick?.()
|
|
102
|
+
}
|
|
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
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
} else {
|
|
127
|
+
mediaPreview = (
|
|
128
|
+
<MediaPreview
|
|
129
|
+
key={source?.sourceUrl}
|
|
130
|
+
sourceUrl={source?.sourceUrl}
|
|
131
|
+
thumbnailUrl={thumbnailUrl}
|
|
132
|
+
mimeType={mimeType}
|
|
133
|
+
LockIcon={LockIcon}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
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
|
+
<div className="px-4 pb-3 pt-3">
|
|
142
|
+
<p className="mb-1.5 truncate text-base font-medium text-black">
|
|
143
|
+
{title}
|
|
144
|
+
</p>
|
|
145
|
+
<div className="flex items-center gap-1">
|
|
146
|
+
{renderTypeIcon(mimeType, {
|
|
147
|
+
className: 'size-5 shrink-0 text-black/55',
|
|
148
|
+
weight: 'regular',
|
|
149
|
+
})}
|
|
150
|
+
{detail != null ? (
|
|
151
|
+
<span className="text-xs font-medium text-black/55">{detail}</span>
|
|
152
|
+
) : null}
|
|
153
|
+
{paymentStatus === 'paid' ? (
|
|
154
|
+
<>
|
|
155
|
+
<span className="text-xs font-medium text-black/55">•</span>
|
|
156
|
+
<span className="text-xs font-medium text-[#008236]">
|
|
157
|
+
Purchased
|
|
158
|
+
</span>
|
|
159
|
+
<CheckCircleIcon
|
|
160
|
+
className="size-4 text-[#008236]"
|
|
161
|
+
weight="bold"
|
|
162
|
+
/>
|
|
163
|
+
</>
|
|
164
|
+
) : amountText != null ? (
|
|
165
|
+
<>
|
|
166
|
+
<span className="text-xs font-medium text-black/55">•</span>
|
|
167
|
+
<span className="text-xs font-medium text-black/55">
|
|
168
|
+
{amountText}
|
|
169
|
+
</span>
|
|
170
|
+
</>
|
|
171
|
+
) : null}
|
|
172
|
+
</div>
|
|
173
|
+
<CardActions
|
|
174
|
+
isLocked={isLocked}
|
|
175
|
+
isUnlocking={isUnlocking}
|
|
176
|
+
sourceUrl={source?.sourceUrl}
|
|
177
|
+
redeemUrl={source?.redeemUrl}
|
|
178
|
+
onUnlockClicked={handleUnlockClick}
|
|
179
|
+
onDownloadClicked={onDownloadClick}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export default VisitorCard
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { DownloadSimpleIcon, LockSimpleIcon } from '@phosphor-icons/react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
interface CardActionsProps {
|
|
5
|
+
isLocked: boolean
|
|
6
|
+
isUnlocking?: boolean
|
|
7
|
+
sourceUrl?: string
|
|
8
|
+
redeemUrl?: string
|
|
9
|
+
onUnlockClicked?: () => void
|
|
10
|
+
onDownloadClicked?: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CardActions: React.FC<CardActionsProps> = (props) => {
|
|
14
|
+
const {
|
|
15
|
+
isLocked,
|
|
16
|
+
isUnlocking = false,
|
|
17
|
+
sourceUrl,
|
|
18
|
+
redeemUrl,
|
|
19
|
+
onUnlockClicked,
|
|
20
|
+
onDownloadClicked,
|
|
21
|
+
} = props
|
|
22
|
+
|
|
23
|
+
if (isLocked && onUnlockClicked != null) {
|
|
24
|
+
return (
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
onClick={onUnlockClicked}
|
|
28
|
+
disabled={isUnlocking}
|
|
29
|
+
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"
|
|
30
|
+
>
|
|
31
|
+
{isUnlocking ? (
|
|
32
|
+
<LoadingDots />
|
|
33
|
+
) : (
|
|
34
|
+
<React.Fragment>
|
|
35
|
+
<LockSimpleIcon className="size-4" weight="fill" />
|
|
36
|
+
Unlock
|
|
37
|
+
</React.Fragment>
|
|
38
|
+
)}
|
|
39
|
+
</button>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!isLocked && onDownloadClicked != null && sourceUrl != null) {
|
|
44
|
+
return (
|
|
45
|
+
<a
|
|
46
|
+
href={redeemUrl ?? sourceUrl}
|
|
47
|
+
target="_blank"
|
|
48
|
+
rel="noopener noreferrer"
|
|
49
|
+
onClick={onDownloadClicked}
|
|
50
|
+
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]"
|
|
51
|
+
>
|
|
52
|
+
<DownloadSimpleIcon className="size-4" weight="bold" />
|
|
53
|
+
Download
|
|
54
|
+
</a>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const LoadingDots: React.FC = () => {
|
|
62
|
+
return (
|
|
63
|
+
<span className="flex items-center gap-1">
|
|
64
|
+
<span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.3s]" />
|
|
65
|
+
<span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.15s]" />
|
|
66
|
+
<span className="size-1 rounded-full bg-white animate-bounce" />
|
|
67
|
+
</span>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default CardActions
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import ThumbnailPreview from './CardThumbnailPreview'
|
|
4
|
+
|
|
5
|
+
interface ImagePreviewProps {
|
|
6
|
+
sourceUrl?: string
|
|
7
|
+
thumbnailUrl?: string
|
|
8
|
+
mimeType: string
|
|
9
|
+
title?: string
|
|
10
|
+
LockIcon?: React.ElementType
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ImagePreview: React.FC<ImagePreviewProps> = (props) => {
|
|
14
|
+
const { sourceUrl, thumbnailUrl, mimeType, title, LockIcon } = props
|
|
15
|
+
const [sourceReady, setSourceReady] = useState(false)
|
|
16
|
+
|
|
17
|
+
if (LockIcon != null) {
|
|
18
|
+
return (
|
|
19
|
+
<ThumbnailPreview
|
|
20
|
+
thumbnailUrl={thumbnailUrl}
|
|
21
|
+
mimeType={mimeType}
|
|
22
|
+
LockIcon={LockIcon}
|
|
23
|
+
/>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="relative overflow-hidden bg-black/5">
|
|
29
|
+
<img
|
|
30
|
+
src={sourceUrl}
|
|
31
|
+
alt={title}
|
|
32
|
+
className={`block w-full transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
|
|
33
|
+
onLoad={() => setSourceReady(true)}
|
|
34
|
+
/>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default ImagePreview
|