From 7a58b9d46945ac8fba863403d26f9f8667433d5e Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 21 Sep 2026 18:27:37 +0330 Subject: [PATCH] Adding contacts from the phone to the customer list --- .../src/components/PhoneContactsImport.vue | 353 ++++++++++++++++++ apps/web/src/lib/phone-contacts.ts | 225 +++++++++++ apps/web/src/views/ContactsView.vue | 49 ++- 3 files changed, 620 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/PhoneContactsImport.vue create mode 100644 apps/web/src/lib/phone-contacts.ts diff --git a/apps/web/src/components/PhoneContactsImport.vue b/apps/web/src/components/PhoneContactsImport.vue new file mode 100644 index 0000000..ba54ce6 --- /dev/null +++ b/apps/web/src/components/PhoneContactsImport.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/apps/web/src/lib/phone-contacts.ts b/apps/web/src/lib/phone-contacts.ts new file mode 100644 index 0000000..0f0bd9d --- /dev/null +++ b/apps/web/src/lib/phone-contacts.ts @@ -0,0 +1,225 @@ +/** مخاطب خامِ آماده‌ی ورود به 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(): Promise { + const api = contactsApi(); + if (!api) { + throw new Error('CONTACT_PICKER_UNSUPPORTED'); + } + const rows = await api.select(['name', 'email', 'tel'], { multiple: true }); + 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; +} + +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); +} diff --git a/apps/web/src/views/ContactsView.vue b/apps/web/src/views/ContactsView.vue index 77ee2e6..e5df91d 100644 --- a/apps/web/src/views/ContactsView.vue +++ b/apps/web/src/views/ContactsView.vue @@ -6,6 +6,7 @@ import { api } from '@/lib/api'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import CustomerMoodFace from '@/components/CustomerMoodFace.vue'; import ExcelImportFlow from '@/components/ExcelImportFlow.vue'; +import PhoneContactsImport from '@/components/PhoneContactsImport.vue'; import { UiButton, UiModal, @@ -213,6 +214,31 @@ async function onImportDone(data: ImportContactsResult) { await load(); } +function onPhoneImportStart() { + busy.value = true; + importMsg.value = ''; + error.value = ''; +} + +async function onPhoneImportDone(data: { + created: number; + skipped: number; + failed: number; +}) { + importMsg.value = `از گوشی وارد شد: ${toFaDigits(data.created)} · تکراری: ${toFaDigits(data.skipped)}${ + data.failed ? ` · ناموفق: ${toFaDigits(data.failed)}` : '' + }`; + await load(); +} + +function onPhoneImportFail(message: string) { + error.value = message; +} + +function onPhoneImportSettled() { + busy.value = false; +} + function onImportFail(message: string) { error.value = message; } @@ -257,6 +283,20 @@ onMounted(load); ورود + + + + از مخاطبین گوشی + گوشی + +