diff --git a/apps/web/src/lib/lead-form-notes.ts b/apps/web/src/lib/lead-form-notes.ts index fd72c81..0ffdae7 100644 --- a/apps/web/src/lib/lead-form-notes.ts +++ b/apps/web/src/lib/lead-form-notes.ts @@ -50,7 +50,78 @@ export function parseLeadFormNotes(raw: string | null | undefined): LeadNoteView } export function leadNoteFileIsImage(file: LeadNoteFile): boolean { - return file.mime.startsWith('image/') || (file.dataUrl ?? '').startsWith('data:image/'); + return ( + file.mime.startsWith('image/') || + (file.dataUrl ?? '').startsWith('data:image/') || + /\.(png|jpe?g|gif|webp)$/i.test(file.name) + ); +} + +export type NoteChip = + | { kind: 'text'; text: string } + | { kind: 'image'; src: string; name: string } + | { kind: 'link'; href: string; label: string }; + +const FILE_BLOB = /\{[^{}]*"dataUrl"\s*:\s*"data:[^"]+"[^{}]*\}/gi; +const DATA_IMAGE = /data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+/gi; +const HTTP_URL = /https?:\/\/[^\s<>"']+/gi; + +/** متن یادداشت را به نوشته، لینک کوتاه و تصویر جدا می‌کند. */ +export function noteChips(raw: string | null | undefined): NoteChip[] { + const text = raw ?? ''; + if (!text.trim()) return []; + const chips: NoteChip[] = []; + const token = new RegExp( + `${FILE_BLOB.source}|${DATA_IMAGE.source}|${HTTP_URL.source}`, + 'gi', + ); + let cursor = 0; + for (const match of text.matchAll(token)) { + const index = match.index ?? 0; + pushText(chips, text.slice(cursor, index)); + chips.push(chipForToken(match[0])); + cursor = index + match[0].length; + } + pushText(chips, text.slice(cursor)); + return chips; +} + +function pushText(chips: NoteChip[], value: string) { + if (!value) return; + const prev = chips[chips.length - 1]; + if (prev?.kind === 'text') prev.text += value; + else chips.push({ kind: 'text', text: value }); +} + +function chipForToken(token: string): NoteChip { + if (token.startsWith('{')) { + const file = readFileValue(token); + if (file?.dataUrl && leadNoteFileIsImage(file)) { + return { kind: 'image', src: file.dataUrl, name: file.name }; + } + if (file?.dataUrl) return { kind: 'link', href: file.dataUrl, label: file.name }; + return { kind: 'text', text: file?.name || 'فایل پیوست' }; + } + if (token.startsWith('data:image/')) { + return { kind: 'image', src: token, name: 'تصویر پیوست' }; + } + if (isImageUrl(token)) return { kind: 'image', src: token, name: shortUrl(token) }; + return { kind: 'link', href: token, label: shortUrl(token) }; +} + +function isImageUrl(href: string): boolean { + return /\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(href); +} + +function shortUrl(href: string): string { + try { + const url = new URL(href); + const file = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() ?? ''); + const label = file || url.hostname; + return label.length > 42 ? `${label.slice(0, 24)}…${label.slice(-10)}` : label; + } catch { + return href.length > 42 ? `${href.slice(0, 28)}…` : href; + } } /** فیلد تاریخ تولد روی خود لید هست؛ جواب فرم نباید دوباره ردیف بسازد. */ @@ -111,7 +182,8 @@ function readFileValue(value: string): LeadNoteFile | null { return asFile(JSON.parse(value)); } catch { const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim(); - if (name) return { name, mime: '', dataUrl: null }; + const mime = value.match(/"mime"\s*:\s*"([^"]+)"/)?.[1]?.trim() ?? ''; + if (name) return { name, mime, dataUrl: null }; if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null }; return null; } diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 8a567bb..d97bffa 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -25,6 +25,13 @@ import { useAuthStore } from '@/stores/auth'; import { canAssignToOthers } from '@/lib/assign-access'; import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { brand } from '@mento/shared'; +import { + leadNoteFileIsImage, + noteChips, + parseLeadFormNotes, + type LeadNoteFile, + type LeadNoteRow, +} from '@/lib/lead-form-notes'; const route = useRoute(); const router = useRouter(); @@ -62,6 +69,51 @@ const moreOpen = ref(false); const commentOpen = ref(false); const postponeOpen = ref(false); const noteOpen = ref(true); +const noteModel = computed(() => parseLeadFormNotes(activity.value?.description)); +const noteRows = computed(() => + noteModel.value.mode === 'answers' ? noteModel.value.rows : ([] as LeadNoteRow[]), +); +const noteExtra = computed(() => + noteChips( + noteModel.value.mode === 'answers' ? noteModel.value.prose : activity.value?.description, + ).filter( + (chip) => !(chip.kind === 'text' && chip.text.replace(/\s+/g, '').trim() === 'پاسخ‌هایفرم:'), + ), +); +const recoveredImages = ref>({}); + +function noteImageSrc(file: LeadNoteFile): string | null { + if (!leadNoteFileIsImage(file)) return null; + return file.dataUrl || recoveredImages.value[file.name] || null; +} + +watch( + [() => activity.value?.id, () => activity.value?.lead?.id, noteRows], + async ([activityId, leadId]) => { + const missing = noteRows.value + .map((row) => row.file) + .filter((file): file is LeadNoteFile => Boolean(file && !file.dataUrl && leadNoteFileIsImage(file))) + .map((file) => file.name); + recoveredImages.value = {}; + if (!activityId || !leadId || !missing.length) return; + try { + const { data } = await api.get<{ notes?: string | null }>(`/leads/${leadId}`); + if (activity.value?.id !== activityId) return; + const wanted = new Set(missing); + const next: Record = {}; + for (const row of parseLeadFormNotes(data.notes).rows) { + const file = row.file; + if (file?.dataUrl && wanted.has(file.name) && leadNoteFileIsImage(file)) { + next[file.name] = file.dataUrl; + } + } + recoveredImages.value = next; + } catch { + recoveredImages.value = {}; + } + }, + { immediate: true }, +); const users = ref([]); const assignUserIds = ref([]); @@ -565,8 +617,61 @@ onMounted(() => {