Files
Wyndham-RSVN/client/src/components/reservation/ReservationV4PaymentAttachmentPreview.vue
T

561 lines
14 KiB
Vue

<template>
<section
v-if="attachments.length"
class="payment-attachments"
data-testid="payment-attachment-preview"
>
<div class="payment-attachments__header">
<div>
<h3>{{ t('taskV4.paymentAttachments.title') }}</h3>
<p v-if="loading">
{{ t('taskV4.paymentAttachments.loading') }}
</p>
<p v-else-if="loadErrorMessage">
{{ loadErrorMessage }}
</p>
</div>
</div>
<ul class="payment-attachments__grid">
<li
v-for="attachment in resolvedAttachments"
:key="attachment.key"
class="payment-attachment"
>
<button
v-if="canPreview(attachment)"
type="button"
class="payment-attachment__thumbnail"
data-testid="payment-attachment-thumbnail"
:aria-label="t('taskV4.paymentAttachments.openPreview')"
@click="selectedPreview = attachment"
>
<img
:src="attachment.mediaUrl"
:alt="attachment.fileName"
>
</button>
<div
v-else
class="payment-attachment__file"
aria-hidden="true"
>
{{ attachment.isImage ? 'IMG' : 'FILE' }}
</div>
<div class="payment-attachment__body">
<strong>{{ attachment.fileName }}</strong>
<small>{{ attachmentMeta(attachment) }}</small>
<small
v-if="attachmentStatus(attachment)"
class="payment-attachment__status"
>
{{ attachmentStatus(attachment) }}
</small>
</div>
<a
v-if="canDownload(attachment)"
class="payment-attachment__download"
data-testid="payment-attachment-download"
:href="attachment.mediaUrl"
:download="attachment.fileName"
target="_blank"
rel="noopener noreferrer"
>
{{ t('taskV4.paymentAttachments.download') }}
</a>
<span
v-else-if="!attachment.isImage"
class="payment-attachment__disabled"
>
{{ t('taskV4.paymentAttachments.downloadUnavailable') }}
</span>
</li>
</ul>
<div
v-if="selectedPreview"
class="payment-lightbox"
data-testid="payment-attachment-modal"
role="dialog"
aria-modal="true"
@click.self="selectedPreview = null"
>
<div class="payment-lightbox__panel">
<button
type="button"
class="payment-lightbox__close"
:aria-label="t('taskV4.paymentAttachments.closePreview')"
@click="selectedPreview = null"
>
{{ t('taskV4.paymentAttachments.closePreview') }}
</button>
<img
:src="selectedPreview.mediaUrl"
:alt="selectedPreview.fileName"
>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { fetchSourceMessageConversation } from '@/services/reservationService'
import type {
ReservationRecord,
ReservationV4PaymentAttachmentSummary,
ReservationV4TaskCardResult,
SourceMessageOriginalMedia,
} from '@/types/reservation'
type PaymentAttachmentItem = {
key: string
attachmentId: string
externalMediaId: string
fileName: string
contentType: string
sizeBytes: number | null
isImage: boolean
previewAvailable: boolean
downloadAvailable: boolean
unavailableReason: string
}
type ResolvedPaymentAttachment = PaymentAttachmentItem & {
media: SourceMessageOriginalMedia | null
mediaUrl: string
}
const props = defineProps<{
card: ReservationV4TaskCardResult
sourceMessageId?: string | null
}>()
const { t } = useI18n()
const currentMessageMedia = ref<SourceMessageOriginalMedia[]>([])
const loading = ref(false)
const loadError = ref(false)
const selectedPreview = ref<ResolvedPaymentAttachment | null>(null)
let requestSequence = 0
const attachments = computed(() => normalizePaymentAttachments(props.card.display_payload?.payment_attachments))
const attachmentSignature = computed(() =>
attachments.value
.map((attachment) => `${attachment.externalMediaId}:${attachment.attachmentId}:${attachment.fileName}`)
.join('|'),
)
const loadErrorMessage = computed(() => {
if (!attachments.value.length) {
return ''
}
if (!props.sourceMessageId) {
return t('taskV4.paymentAttachments.missingSourceMessage')
}
return loadError.value ? t('taskV4.paymentAttachments.loadFailed') : ''
})
const resolvedAttachments = computed<ResolvedPaymentAttachment[]>(() =>
attachments.value.map((attachment) => {
const media = matchCurrentMessageMedia(attachment)
return {
...attachment,
media,
mediaUrl: media ? safeMediaUrl(media) : '',
}
}),
)
watch(
[() => props.sourceMessageId, attachmentSignature],
() => {
void loadCurrentMessageMedia()
},
{ immediate: true },
)
async function loadCurrentMessageMedia(): Promise<void> {
const currentRequest = ++requestSequence
currentMessageMedia.value = []
loadError.value = false
selectedPreview.value = null
if (!props.sourceMessageId || !attachments.value.length) {
loading.value = false
return
}
loading.value = true
try {
const conversation = await fetchSourceMessageConversation(props.sourceMessageId)
if (currentRequest !== requestSequence) {
return
}
const currentMessage = conversation.messages.find((message) =>
String(message.id) === String(props.sourceMessageId),
)
currentMessageMedia.value = currentMessage
? [...currentMessage.attachments, ...currentMessage.inline_images]
: []
} catch {
if (currentRequest === requestSequence) {
loadError.value = true
}
} finally {
if (currentRequest === requestSequence) {
loading.value = false
}
}
}
function normalizePaymentAttachments(value: unknown): PaymentAttachmentItem[] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item, index) => normalizePaymentAttachment(item, index))
.filter((item): item is PaymentAttachmentItem => Boolean(item))
}
function normalizePaymentAttachment(value: unknown, index: number): PaymentAttachmentItem | null {
const record = readRecord(value) as ReservationV4PaymentAttachmentSummary | null
if (!record) {
return null
}
const attachmentId = readFirstString(record, ['attachment_id', 'attachmentId', 'id'])
const externalMediaId = readFirstString(record, ['external_media_id', 'externalMediaId'])
const fileName = safeFileName(readFirstString(record, ['file_name', 'fileName', 'name', 'display_name']))
const contentType = readFirstString(record, ['content_type', 'contentType', 'mime_type']) ?? ''
const sizeBytes = readNumber(record, 'size_bytes') ?? readNumber(record, 'sizeBytes') ?? readNumber(record, 'size')
const isImage = readBoolean(record, 'is_image') ?? contentType.toLowerCase().startsWith('image/')
return {
key: externalMediaId || attachmentId || `${fileName}-${index}`,
attachmentId,
externalMediaId,
fileName,
contentType,
sizeBytes,
isImage,
previewAvailable: readBoolean(record, 'preview_available') ?? true,
downloadAvailable: readBoolean(record, 'download_available') ?? true,
unavailableReason: readFirstString(record, ['unavailable_reason_code', 'unavailableReasonCode']) ?? '',
}
}
function matchCurrentMessageMedia(attachment: PaymentAttachmentItem): SourceMessageOriginalMedia | null {
if (attachment.externalMediaId) {
const matchedByExternalMediaId = currentMessageMedia.value.find((media) =>
mediaExternalMediaId(media) === attachment.externalMediaId,
)
if (matchedByExternalMediaId) {
return matchedByExternalMediaId
}
}
if (attachment.attachmentId) {
return currentMessageMedia.value.find((media) =>
mediaAttachmentIds(media).includes(attachment.attachmentId),
) ?? null
}
return null
}
function canPreview(attachment: ResolvedPaymentAttachment): boolean {
return attachment.isImage &&
attachment.previewAvailable &&
Boolean(attachment.mediaUrl) &&
!loadError.value
}
function canDownload(attachment: ResolvedPaymentAttachment): boolean {
return !attachment.isImage &&
attachment.downloadAvailable &&
Boolean(attachment.mediaUrl) &&
!loadError.value
}
function attachmentStatus(attachment: ResolvedPaymentAttachment): string {
if (loadErrorMessage.value) {
return loadErrorMessage.value
}
if (!attachment.media) {
return t('taskV4.paymentAttachments.noMatchedMedia')
}
if (attachment.unavailableReason) {
return t('taskV4.paymentAttachments.unavailableReason', { reason: attachment.unavailableReason })
}
if (attachment.isImage && !attachment.previewAvailable) {
return t('taskV4.paymentAttachments.previewUnavailable')
}
if (!attachment.isImage && !attachment.downloadAvailable) {
return t('taskV4.paymentAttachments.downloadUnavailable')
}
return ''
}
function attachmentMeta(attachment: PaymentAttachmentItem): string {
const type = attachment.contentType || (attachment.isImage ? 'image' : 'file')
const size = formatFileSize(attachment.sizeBytes)
return size
? t('taskV4.paymentAttachments.fileMeta', { type, size })
: type
}
function mediaExternalMediaId(media: SourceMessageOriginalMedia): string {
return media.externalMediaId ?? readFirstString(media as unknown as ReservationRecord, ['external_media_id']) ?? ''
}
function mediaAttachmentIds(media: SourceMessageOriginalMedia): string[] {
const record = media as unknown as ReservationRecord
return [
readFirstString(record, ['attachment_id', 'attachmentId', 'id']),
mediaExternalMediaId(media),
].filter(Boolean)
}
function safeMediaUrl(media: SourceMessageOriginalMedia): string {
return media.externalUrl ?? readFirstString(media as unknown as ReservationRecord, ['external_url', 'url']) ?? ''
}
function readRecord(value: unknown): ReservationRecord | null {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
? value as ReservationRecord
: null
}
function readFirstString(record: ReservationRecord, keys: string[]): string {
for (const key of keys) {
const value = record[key]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return ''
}
function readNumber(record: ReservationRecord, key: string): number | null {
const value = record[key]
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && Number.isFinite(Number(value))) {
return Number(value)
}
return null
}
function readBoolean(record: ReservationRecord, key: string): boolean | null {
const value = record[key]
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase()
if (normalizedValue === 'true') {
return true
}
if (normalizedValue === 'false') {
return false
}
}
return null
}
function safeFileName(value: string): string {
return value || t('conversation.unnamedAttachment')
}
function formatFileSize(size: number | null): string {
if (!size) {
return ''
}
if (size < 1024) {
return `${size} B`
}
if (size < 1024 * 1024) {
return `${Math.round(size / 1024)} KB`
}
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
</script>
<style scoped>
.payment-attachments {
display: grid;
gap: 12px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-slate-50);
padding: 14px;
}
.payment-attachments__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.payment-attachments__header h3,
.payment-attachments__header p {
margin: 0;
}
.payment-attachments__header h3 {
color: var(--th-color-slate-900);
font-size: 14px;
}
.payment-attachments__header p {
margin-top: 4px;
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
}
.payment-attachments__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 10px;
margin: 0;
padding: 0;
}
.payment-attachment {
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
min-width: 0;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
list-style: none;
padding: 10px;
}
.payment-attachment__thumbnail,
.payment-attachment__file {
width: 88px;
height: 64px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-slate-50);
}
.payment-attachment__thumbnail {
cursor: zoom-in;
overflow: hidden;
padding: 0;
}
.payment-attachment__thumbnail img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.payment-attachment__file {
display: grid;
place-items: center;
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 900;
}
.payment-attachment__body {
display: grid;
gap: 4px;
min-width: 0;
}
.payment-attachment__body strong {
color: var(--th-color-slate-900);
font-size: 13px;
overflow-wrap: anywhere;
}
.payment-attachment__body small,
.payment-attachment__disabled {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
}
.payment-attachment__status {
color: var(--th-color-warning);
}
.payment-attachment__download {
color: var(--th-color-blue-600);
font-size: 13px;
font-weight: 900;
text-decoration: none;
white-space: nowrap;
}
.payment-attachment__download:hover {
text-decoration: underline;
}
.payment-lightbox {
position: fixed;
z-index: 50;
inset: 0;
display: grid;
place-items: center;
background: rgb(15 23 42 / 68%);
padding: 24px;
}
.payment-lightbox__panel {
display: grid;
gap: 12px;
max-width: min(960px, 92vw);
max-height: 92vh;
}
.payment-lightbox__panel img {
max-width: 100%;
max-height: 78vh;
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
object-fit: contain;
}
.payment-lightbox__close {
justify-self: end;
min-height: 36px;
border: 0;
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
color: var(--th-color-slate-900);
cursor: pointer;
font-size: 13px;
font-weight: 900;
padding: 8px 12px;
}
@media (max-width: 760px) {
.payment-attachment {
grid-template-columns: 72px minmax(0, 1fr);
}
.payment-attachment__thumbnail,
.payment-attachment__file {
width: 72px;
height: 56px;
}
.payment-attachment__download,
.payment-attachment__disabled {
grid-column: 2;
justify-self: start;
}
}
</style>