/** مخاطب خامِ آماده‌ی ورود به CRM */ export type PhoneContactDraft = { key: string; firstName: string; lastName: string; fullName: string; mobile?: string; phone?: string; email?: string; }; type ContactInfoLike = { name?: string[]; email?: string[]; tel?: string[]; }; type ContactsManagerLike = { select: ( properties: string[], options?: { multiple?: boolean }, ) => Promise; }; function contactsApi(): ContactsManagerLike | null { const nav = navigator as Navigator & { contacts?: ContactsManagerLike }; if (!nav.contacts || typeof nav.contacts.select !== 'function') return null; return nav.contacts; } export function isContactPickerSupported() { return Boolean(contactsApi()); } function splitName(full: string): { firstName: string; lastName: string } { const parts = full.trim().split(/\s+/).filter(Boolean); if (!parts.length) return { firstName: 'بدون‌نام', lastName: '' }; if (parts.length === 1) return { firstName: parts[0]!, lastName: '' }; return { firstName: parts[0]!, lastName: parts.slice(1).join(' ') }; } function cleanPhone(raw: string): string { return raw.replace(/[^\d+]/g, '').trim(); } function looksMobile(raw: string): boolean { const digits = raw.replace(/\D/g, ''); if (/^09\d{9}$/.test(digits)) return true; if (/^989\d{9}$/.test(digits)) return true; if (/^9\d{9}$/.test(digits)) return true; return /mobile|cell|iphone|موبایل|همراه/i.test(raw); } function pickPhones(tels: string[]): { mobile?: string; phone?: string } { const cleaned = tels.map((t) => cleanPhone(t)).filter(Boolean); if (!cleaned.length) return {}; const mobile = cleaned.find((t) => looksMobile(t)); if (mobile) { const phone = cleaned.find((t) => t !== mobile); return { mobile, phone }; } return { mobile: cleaned[0], phone: cleaned[1] }; } function draftFromParts(input: { fullName: string; firstName?: string; lastName?: string; tels?: string[]; emails?: string[]; }): PhoneContactDraft | null { let firstName = (input.firstName ?? '').trim(); let lastName = (input.lastName ?? '').trim(); const fullName = (input.fullName || `${firstName} ${lastName}`).trim(); if (!firstName && !lastName) { const split = splitName(fullName || 'بدون‌نام'); firstName = split.firstName; lastName = split.lastName; } const phones = pickPhones(input.tels ?? []); const email = (input.emails ?? []).map((e) => e.trim()).find(Boolean); if (!phones.mobile && !phones.phone && !email && !fullName) return null; const key = [ firstName, lastName, phones.mobile ?? '', phones.phone ?? '', email ?? '', ].join('|'); return { key, firstName: firstName || 'بدون‌نام', lastName, fullName: fullName || `${firstName} ${lastName}`.trim(), mobile: phones.mobile, phone: phones.phone, email, }; } export async function pickPhoneContacts(options?: { multiple?: boolean; }): Promise { const api = contactsApi(); if (!api) { throw new Error('CONTACT_PICKER_UNSUPPORTED'); } const multiple = options?.multiple !== false; const rows = await api.select(['name', 'email', 'tel'], { multiple }); const drafts: PhoneContactDraft[] = []; const seen = new Set(); for (const row of rows) { const fullName = (row.name ?? []).map((n) => n.trim()).filter(Boolean).join(' '); const draft = draftFromParts({ fullName, tels: row.tel ?? [], emails: row.email ?? [], }); if (!draft || seen.has(draft.key)) continue; seen.add(draft.key); drafts.push(draft); } return drafts; } /** یک مخاطب برای پر کردن فرم (نه ورود گروهی) */ export async function pickOnePhoneContact(): Promise { const rows = await pickPhoneContacts({ multiple: false }); return rows[0] ?? null; } function unfoldVcard(text: string): string[] { const raw = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); const lines: string[] = []; for (const line of raw) { if (!line) continue; if (/^[ \t]/.test(line) && lines.length) { lines[lines.length - 1] += line.slice(1); } else { lines.push(line); } } return lines; } function parseVcardProp(line: string): { name: string; params: string; value: string } | null { const colon = line.indexOf(':'); if (colon < 0) return null; const left = line.slice(0, colon); const value = line.slice(colon + 1); const semi = left.indexOf(';'); const name = (semi >= 0 ? left.slice(0, semi) : left).toUpperCase(); const params = semi >= 0 ? left.slice(semi + 1) : ''; return { name, params, value }; } /** پارس سادهٔ فایل‌های `.vcf` / vCard 2.1–4.0 */ export function parseVCard(text: string): PhoneContactDraft[] { const lines = unfoldVcard(text); const drafts: PhoneContactDraft[] = []; const seen = new Set(); let inCard = false; let fn = ''; let firstName = ''; let lastName = ''; const tels: string[] = []; const emails: string[] = []; function flush() { const draft = draftFromParts({ fullName: fn, firstName, lastName, tels, emails, }); if (draft && !seen.has(draft.key)) { seen.add(draft.key); drafts.push(draft); } fn = ''; firstName = ''; lastName = ''; tels.length = 0; emails.length = 0; } for (const line of lines) { const upper = line.toUpperCase(); if (upper === 'BEGIN:VCARD') { if (inCard) flush(); inCard = true; continue; } if (upper === 'END:VCARD') { if (inCard) flush(); inCard = false; continue; } if (!inCard) continue; const prop = parseVcardProp(line); if (!prop) continue; if (prop.name === 'FN') { fn = prop.value.trim(); } else if (prop.name === 'N') { const parts = prop.value.split(';'); lastName = (parts[0] ?? '').trim(); firstName = (parts[1] ?? '').trim(); } else if (prop.name === 'TEL') { const tel = cleanPhone(prop.value); if (!tel) continue; if (/CELL|MOBILE|IPHONE|VOICE/i.test(prop.params) || looksMobile(prop.value)) { tels.unshift(tel); } else { tels.push(tel); } } else if (prop.name === 'EMAIL') { const email = prop.value.trim(); if (email) emails.push(email); } } if (inCard) flush(); return drafts; } export async function parseVCardFile(file: File): Promise { const text = await file.text(); return parseVCard(text); } function toEnDigits(raw: string): string { return raw.replace(/[۰-۹٠-٩]/g, (ch) => { const fa = '۰۱۲۳۴۵۶۷۸۹'.indexOf(ch); if (fa >= 0) return String(fa); const ar = '٠١٢٣٤٥٦٧٨٩'.indexOf(ch); return ar >= 0 ? String(ar) : ch; }); } export function sortPhoneContacts(rows: PhoneContactDraft[]): PhoneContactDraft[] { return [...rows].sort((a, b) => a.fullName.localeCompare(b.fullName, 'fa', { sensitivity: 'base', numeric: true }), ); } export function filterPhoneContacts( rows: PhoneContactDraft[], query: string, ): PhoneContactDraft[] { const needle = toEnDigits(query).trim().toLowerCase(); if (!needle) return rows; const digitNeedle = needle.replace(/\D/g, ''); return rows.filter((row) => { const name = toEnDigits(row.fullName).toLowerCase(); if (name.includes(needle)) return true; const phones = [row.mobile, row.phone].filter(Boolean).join(' '); const phonesNorm = toEnDigits(phones).toLowerCase(); if (phonesNorm.includes(needle)) return true; if (digitNeedle && phonesNorm.replace(/\D/g, '').includes(digitNeedle)) return true; return false; }); }