Files
CRM_Montzar/apps/web/src/lib/phone-contacts.ts
T
2026-09-21 20:26:19 +03:30

268 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** مخاطب خامِ آماده‌ی ورود به 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<ContactInfoLike[]>;
};
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<PhoneContactDraft[]> {
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<string>();
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<PhoneContactDraft | null> {
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<string>();
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<PhoneContactDraft[]> {
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;
});
}