2026-09-21 18:27:37 +03:30
|
|
|
|
/** مخاطب خامِ آمادهی ورود به 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 20:04:18 +03:30
|
|
|
|
export async function pickPhoneContacts(options?: {
|
|
|
|
|
|
multiple?: boolean;
|
|
|
|
|
|
}): Promise<PhoneContactDraft[]> {
|
2026-09-21 18:27:37 +03:30
|
|
|
|
const api = contactsApi();
|
|
|
|
|
|
if (!api) {
|
|
|
|
|
|
throw new Error('CONTACT_PICKER_UNSUPPORTED');
|
|
|
|
|
|
}
|
2026-09-21 20:04:18 +03:30
|
|
|
|
const multiple = options?.multiple !== false;
|
|
|
|
|
|
const rows = await api.select(['name', 'email', 'tel'], { multiple });
|
2026-09-21 18:27:37 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 20:04:18 +03:30
|
|
|
|
/** یک مخاطب برای پر کردن فرم (نه ورود گروهی) */
|
|
|
|
|
|
export async function pickOnePhoneContact(): Promise<PhoneContactDraft | null> {
|
|
|
|
|
|
const rows = await pickPhoneContacts({ multiple: false });
|
|
|
|
|
|
return rows[0] ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 18:27:37 +03:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-09-21 20:26:19 +03:30
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|