2026-10-25 20:21:33 +03:30
|
|
|
import { formatJalaliNumeric } from '@/lib/jalali';
|
|
|
|
|
|
|
|
|
|
export type LeadNoteFile = {
|
|
|
|
|
name: string;
|
|
|
|
|
mime: string;
|
|
|
|
|
dataUrl: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type LeadNoteRow = {
|
|
|
|
|
label: string;
|
|
|
|
|
text: string;
|
|
|
|
|
file: LeadNoteFile | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type LeadNoteView = {
|
|
|
|
|
mode: 'prose' | 'answers';
|
|
|
|
|
prose: string;
|
|
|
|
|
rows: LeadNoteRow[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const UTM_LABELS: Record<string, string> = {
|
|
|
|
|
utm_source: 'منبع',
|
|
|
|
|
utm_medium: 'رسانه',
|
|
|
|
|
utm_campaign: 'کمپین',
|
|
|
|
|
utm_term: 'عبارت',
|
|
|
|
|
utm_content: 'محتوا',
|
|
|
|
|
صفحه: 'صفحه',
|
|
|
|
|
ارجاع: 'ارجاع',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** یادداشت لید را اگر جواب فرم باشد به ردیف سؤال/جواب تبدیل میکند. */
|
|
|
|
|
export function parseLeadFormNotes(raw: string | null | undefined): LeadNoteView {
|
|
|
|
|
const text = (raw ?? '').trim();
|
|
|
|
|
if (!text) return { mode: 'prose', prose: '', rows: [] };
|
|
|
|
|
|
|
|
|
|
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
|
|
|
const rows: LeadNoteRow[] = [];
|
|
|
|
|
const prose: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
const row = parseAnswerLine(line);
|
|
|
|
|
if (row) rows.push(row);
|
|
|
|
|
else prose.push(line);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const answersAreMain = rows.length >= 2 && rows.length >= prose.length;
|
|
|
|
|
if (!answersAreMain) return { mode: 'prose', prose: text, rows: [] };
|
|
|
|
|
|
|
|
|
|
return { mode: 'answers', prose: prose.join('\n'), rows };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function leadNoteFileIsImage(file: LeadNoteFile): boolean {
|
2026-11-03 17:30:45 +03:30
|
|
|
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;
|
|
|
|
|
}
|
2026-10-25 20:21:33 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** فیلد تاریخ تولد روی خود لید هست؛ جواب فرم نباید دوباره ردیف بسازد. */
|
|
|
|
|
export function isBirthDateAnswer(label: string): boolean {
|
|
|
|
|
return label.trim() === 'تاریخ تولد';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseAnswerLine(line: string): LeadNoteRow | null {
|
|
|
|
|
const splitAt = line.indexOf(':');
|
|
|
|
|
if (splitAt <= 0 || splitAt > 200) return null;
|
|
|
|
|
const label = line.slice(0, splitAt).trim();
|
|
|
|
|
const value = line.slice(splitAt + 1).trim();
|
|
|
|
|
if (!label || !value || looksLikeUrl(label)) return null;
|
|
|
|
|
|
|
|
|
|
if (label === 'UTM') {
|
|
|
|
|
return { label: 'اطلاعات ورود', text: humanizeUtm(value), file: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const file = readFileValue(value);
|
|
|
|
|
if (file) return { label, text: file.name, file };
|
|
|
|
|
return { label, text: humanizePlain(value), file: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function looksLikeUrl(label: string): boolean {
|
|
|
|
|
return /^https?:\/\//i.test(label);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function humanizePlain(value: string): string {
|
|
|
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
|
|
|
const date = new Date(`${value}T12:00:00`);
|
|
|
|
|
if (!Number.isNaN(date.getTime())) return formatJalaliNumeric(date);
|
|
|
|
|
}
|
|
|
|
|
if (value === 'true') return 'بله';
|
|
|
|
|
if (value === 'false') return 'خیر';
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function humanizeUtm(value: string): string {
|
|
|
|
|
return value
|
|
|
|
|
.split('|')
|
|
|
|
|
.map((part) => part.trim())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map((part) => {
|
|
|
|
|
const eq = part.indexOf('=');
|
|
|
|
|
if (eq <= 0) return part;
|
|
|
|
|
const key = part.slice(0, eq).trim();
|
|
|
|
|
const val = part.slice(eq + 1).trim();
|
|
|
|
|
return `${UTM_LABELS[key] ?? key}: ${val}`;
|
|
|
|
|
})
|
|
|
|
|
.join('، ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readFileValue(value: string): LeadNoteFile | null {
|
|
|
|
|
if (!value.startsWith('{') && !value.includes('"dataUrl"') && !value.includes('"name"')) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
return asFile(JSON.parse(value));
|
|
|
|
|
} catch {
|
|
|
|
|
const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim();
|
2026-11-03 17:30:45 +03:30
|
|
|
const mime = value.match(/"mime"\s*:\s*"([^"]+)"/)?.[1]?.trim() ?? '';
|
|
|
|
|
if (name) return { name, mime, dataUrl: null };
|
2026-10-25 20:21:33 +03:30
|
|
|
if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null };
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asFile(value: unknown): LeadNoteFile | null {
|
|
|
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
|
|
|
const row = value as { name?: unknown; mime?: unknown; dataUrl?: unknown };
|
|
|
|
|
const name = typeof row.name === 'string' ? row.name.trim() : '';
|
|
|
|
|
const mime = typeof row.mime === 'string' ? row.mime.trim() : '';
|
|
|
|
|
const dataUrl = typeof row.dataUrl === 'string' && row.dataUrl.startsWith('data:') ? row.dataUrl : null;
|
|
|
|
|
if (!name && !dataUrl) return null;
|
|
|
|
|
return {
|
|
|
|
|
name: name || 'فایل پیوست',
|
|
|
|
|
mime: mime || (dataUrl?.startsWith('data:image/') ? 'image/' : ''),
|
|
|
|
|
dataUrl,
|
|
|
|
|
};
|
|
|
|
|
}
|