Edit: Add from Android phone contacts
This commit is contained in:
@@ -28,7 +28,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const pickerSupported = isContactPickerSupported();
|
const pickerSupported = isContactPickerSupported();
|
||||||
/** یک مودال با دو مرحله تا بعد از انتخاب فایل، لیست تأیید گم نشود */
|
/** یک مودال با دو مرحله تا بعد از انتخاب فایل، لیست تأیید گم نشود */
|
||||||
const phase = ref<'closed' | 'help' | 'review'>('closed');
|
const phase = ref<'closed' | 'choose' | 'help' | 'review'>('closed');
|
||||||
const importing = ref(false);
|
const importing = ref(false);
|
||||||
const drafts = ref<PhoneContactDraft[]>([]);
|
const drafts = ref<PhoneContactDraft[]>([]);
|
||||||
const selected = ref<Set<string>>(new Set());
|
const selected = ref<Set<string>>(new Set());
|
||||||
@@ -36,23 +36,17 @@ const listQuery = ref('');
|
|||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const modalOpen = computed(() => phase.value !== 'closed');
|
const modalOpen = computed(() => phase.value !== 'closed');
|
||||||
const modalTitle = computed(() =>
|
const modalTitle = computed(() => {
|
||||||
phase.value === 'review' ? 'انتخاب مخاطبین برای ورود' : 'ورود از مخاطبین گوشی',
|
if (phase.value === 'review') return 'انتخاب مخاطبین برای ورود';
|
||||||
);
|
if (phase.value === 'choose') return 'ورود از مخاطبین گوشی';
|
||||||
|
return 'ورود از فایل مخاطبین';
|
||||||
|
});
|
||||||
const visibleDrafts = computed(() =>
|
const visibleDrafts = computed(() =>
|
||||||
filterPhoneContacts(drafts.value, listQuery.value),
|
filterPhoneContacts(drafts.value, listQuery.value),
|
||||||
);
|
);
|
||||||
const selectedCount = computed(
|
const selectedCount = computed(
|
||||||
() => drafts.value.filter((d) => selected.value.has(d.key)).length,
|
() => drafts.value.filter((d) => selected.value.has(d.key)).length,
|
||||||
);
|
);
|
||||||
const allSelected = computed(
|
|
||||||
() => drafts.value.length > 0 && selectedCount.value === drafts.value.length,
|
|
||||||
);
|
|
||||||
const visibleAllSelected = computed(
|
|
||||||
() =>
|
|
||||||
visibleDrafts.value.length > 0 &&
|
|
||||||
visibleDrafts.value.every((d) => selected.value.has(d.key)),
|
|
||||||
);
|
|
||||||
|
|
||||||
function apiMessage(e: unknown, fallback: string) {
|
function apiMessage(e: unknown, fallback: string) {
|
||||||
if (axios.isAxiosError(e)) {
|
if (axios.isAxiosError(e)) {
|
||||||
@@ -111,18 +105,19 @@ function openHelp() {
|
|||||||
phase.value = 'help';
|
phase.value = 'help';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startPicker() {
|
function openChoose() {
|
||||||
|
phase.value = 'choose';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDevicePicker() {
|
||||||
try {
|
try {
|
||||||
if (!pickerSupported) {
|
|
||||||
openHelp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rows = await pickPhoneContacts({ multiple: true });
|
const rows = await pickPhoneContacts({ multiple: true });
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
emit('fail', 'مخاطبی انتخاب نشد.');
|
emit('fail', 'مخاطبی انتخاب نشد.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await showReview(rows, { selectAll: true });
|
// مثل vcf: جستجو/انتخاب دستی، نه ورود خودکار همه
|
||||||
|
await showReview(rows, { selectAll: false });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const name = e instanceof DOMException ? e.name : '';
|
const name = e instanceof DOMException ? e.name : '';
|
||||||
const message = e instanceof Error ? e.message : '';
|
const message = e instanceof Error ? e.message : '';
|
||||||
@@ -135,6 +130,14 @@ async function startPicker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startPicker() {
|
||||||
|
if (pickerSupported) {
|
||||||
|
openChoose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openHelp();
|
||||||
|
}
|
||||||
|
|
||||||
async function onVcfFile(ev: Event) {
|
async function onVcfFile(ev: Event) {
|
||||||
const el = ev.target as HTMLInputElement;
|
const el = ev.target as HTMLInputElement;
|
||||||
const file = el.files?.[0];
|
const file = el.files?.[0];
|
||||||
@@ -144,13 +147,13 @@ async function onVcfFile(ev: Event) {
|
|||||||
const rows = await parseVCardFile(file);
|
const rows = await parseVCardFile(file);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
emit('fail', 'مخاطبی برای ورود پیدا نشد.');
|
emit('fail', 'مخاطبی برای ورود پیدا نشد.');
|
||||||
phase.value = 'help';
|
phase.value = pickerSupported ? 'choose' : 'help';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await showReview(rows, { selectAll: false });
|
await showReview(rows, { selectAll: false });
|
||||||
} catch {
|
} catch {
|
||||||
emit('fail', 'خواندن فایل مخاطبین (.vcf) ناموفق بود.');
|
emit('fail', 'خواندن فایل مخاطبین (.vcf) ناموفق بود.');
|
||||||
phase.value = 'help';
|
phase.value = pickerSupported ? 'choose' : 'help';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +216,23 @@ async function importSelected() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
|
<UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
|
||||||
<template v-if="phase === 'help'">
|
<template v-if="phase === 'choose'">
|
||||||
|
<p class="lead">
|
||||||
|
مخاطبین را از دفترچه تلفن یا فایل <strong>.vcf</strong> بگیرید.
|
||||||
|
بعد در لیست الفبایی جستجو کنید و یک، چند، یا همه را برای ورود انتخاب کنید.
|
||||||
|
</p>
|
||||||
|
<div class="choose-actions">
|
||||||
|
<UiButton variant="primary" block @click="runDevicePicker">
|
||||||
|
<UiIcon name="phone" :size="16" />
|
||||||
|
از دفترچه تلفن
|
||||||
|
</UiButton>
|
||||||
|
<UiButton variant="ghost" block @click="fileInput?.click()">
|
||||||
|
از فایل .vcf
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="phase === 'help'">
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمیشود.
|
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمیشود.
|
||||||
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
|
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
|
||||||
@@ -275,7 +294,10 @@ async function importSelected() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<template v-if="phase === 'help'">
|
<template v-if="phase === 'choose'">
|
||||||
|
<UiButton variant="ghost" @click="cancel">انصراف</UiButton>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="phase === 'help'">
|
||||||
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
|
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
|
||||||
<UiButton variant="primary" :disabled="importing" @click="fileInput?.click()">
|
<UiButton variant="primary" :disabled="importing" @click="fileInput?.click()">
|
||||||
انتخاب فایل .vcf
|
انتخاب فایل .vcf
|
||||||
@@ -293,15 +315,7 @@ async function importSelected() {
|
|||||||
@click="importSelected"
|
@click="importSelected"
|
||||||
>
|
>
|
||||||
<UiIcon name="user-plus" :size="16" />
|
<UiIcon name="user-plus" :size="16" />
|
||||||
<template v-if="allSelected && selectedCount && !listQuery.trim()">
|
ورود {{ toFaDigits(selectedCount) }} نفر
|
||||||
ورود همه ({{ toFaDigits(selectedCount) }})
|
|
||||||
</template>
|
|
||||||
<template v-else-if="visibleAllSelected && listQuery.trim() && selectedCount">
|
|
||||||
ورود {{ toFaDigits(selectedCount) }} نفر
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
ورود {{ toFaDigits(selectedCount) }} نفر
|
|
||||||
</template>
|
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -329,6 +343,11 @@ async function importSelected() {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.choose-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tips {
|
.tips {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 1.1rem 0 0;
|
padding: 0 1.1rem 0 0;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import UiIcon from '@/components/UiIcon.vue';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** برای مودال روی مودال (مثل انتخاب مخاطب داخل فرم) */
|
||||||
|
nest?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -29,7 +31,7 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="open" class="backdrop" @click="onBackdrop">
|
<div v-if="open" class="backdrop" :class="{ nest }" @click="onBackdrop">
|
||||||
<section
|
<section
|
||||||
class="dialog"
|
class="dialog"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -65,6 +67,10 @@ function onBackdrop(event: MouseEvent) {
|
|||||||
background: rgba(15, 23, 42, 0.35);
|
background: rgba(15, 23, 42, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backdrop.nest {
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog {
|
.dialog {
|
||||||
width: min(880px, 100%);
|
width: min(880px, 100%);
|
||||||
max-height: min(88dvh, 88vh, 880px);
|
max-height: min(88dvh, 88vh, 880px);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
filterPhoneContacts,
|
filterPhoneContacts,
|
||||||
isContactPickerSupported,
|
isContactPickerSupported,
|
||||||
parseVCardFile,
|
parseVCardFile,
|
||||||
pickOnePhoneContact,
|
pickPhoneContacts,
|
||||||
sortPhoneContacts,
|
sortPhoneContacts,
|
||||||
type PhoneContactDraft,
|
type PhoneContactDraft,
|
||||||
} from '@/lib/phone-contacts';
|
} from '@/lib/phone-contacts';
|
||||||
@@ -69,6 +69,7 @@ const loyaltyTierName = ref('');
|
|||||||
const contactPickerSupported = isContactPickerSupported();
|
const contactPickerSupported = isContactPickerSupported();
|
||||||
const pickingContact = ref(false);
|
const pickingContact = ref(false);
|
||||||
const contactFileInput = ref<HTMLInputElement | null>(null);
|
const contactFileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const sourceOpen = ref(false);
|
||||||
const pickOpen = ref(false);
|
const pickOpen = ref(false);
|
||||||
const pickDrafts = ref<PhoneContactDraft[]>([]);
|
const pickDrafts = ref<PhoneContactDraft[]>([]);
|
||||||
const pickQuery = ref('');
|
const pickQuery = ref('');
|
||||||
@@ -112,6 +113,10 @@ function closePick() {
|
|||||||
pickQuery.value = '';
|
pickQuery.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeSource() {
|
||||||
|
sourceOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
function choosePick(row: PhoneContactDraft) {
|
function choosePick(row: PhoneContactDraft) {
|
||||||
applyContactDraft(row);
|
applyContactDraft(row);
|
||||||
closePick();
|
closePick();
|
||||||
@@ -120,23 +125,34 @@ function choosePick(row: PhoneContactDraft) {
|
|||||||
async function openPickList(rows: PhoneContactDraft[]) {
|
async function openPickList(rows: PhoneContactDraft[]) {
|
||||||
pickDrafts.value = sortPhoneContacts(rows);
|
pickDrafts.value = sortPhoneContacts(rows);
|
||||||
pickQuery.value = '';
|
pickQuery.value = '';
|
||||||
|
sourceOpen.value = false;
|
||||||
pickOpen.value = false;
|
pickOpen.value = false;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
pickOpen.value = true;
|
pickOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fillFromPhoneContact() {
|
function fillFromPhoneContact() {
|
||||||
|
if (pickingContact.value || saving.value) return;
|
||||||
|
error.value = '';
|
||||||
|
if (contactPickerSupported) {
|
||||||
|
sourceOpen.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contactFileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFromDevice() {
|
||||||
if (pickingContact.value || saving.value) return;
|
if (pickingContact.value || saving.value) return;
|
||||||
pickingContact.value = true;
|
pickingContact.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
|
sourceOpen.value = false;
|
||||||
try {
|
try {
|
||||||
if (!contactPickerSupported) {
|
const rows = await pickPhoneContacts({ multiple: true });
|
||||||
contactFileInput.value?.click();
|
if (!rows.length) {
|
||||||
|
error.value = 'مخاطبی انتخاب نشد.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const draft = await pickOnePhoneContact();
|
await openPickList(rows);
|
||||||
if (!draft) return;
|
|
||||||
applyContactDraft(draft);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const name = e instanceof DOMException ? e.name : '';
|
const name = e instanceof DOMException ? e.name : '';
|
||||||
const message = e instanceof Error ? e.message : '';
|
const message = e instanceof Error ? e.message : '';
|
||||||
@@ -151,6 +167,11 @@ async function fillFromPhoneContact() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickFromVcf() {
|
||||||
|
sourceOpen.value = false;
|
||||||
|
contactFileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
async function onContactVcfFile(ev: Event) {
|
async function onContactVcfFile(ev: Event) {
|
||||||
const el = ev.target as HTMLInputElement;
|
const el = ev.target as HTMLInputElement;
|
||||||
const file = el.files?.[0];
|
const file = el.files?.[0];
|
||||||
@@ -339,9 +360,27 @@ onMounted(load);
|
|||||||
@change="onContactVcfFile"
|
@change="onContactVcfFile"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UiModal :open="pickOpen" title="انتخاب مخاطب" @close="closePick">
|
<UiModal nest :open="sourceOpen" title="از مخاطبین" @close="closeSource">
|
||||||
<p class="pick-lead">
|
<p class="pick-lead">
|
||||||
{{ toFaDigits(pickDrafts.length) }} مخاطب در فایل (مرتبشده الفبایی).
|
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
|
||||||
|
</p>
|
||||||
|
<div class="pick-choose">
|
||||||
|
<UiButton variant="primary" block :disabled="pickingContact" @click="pickFromDevice">
|
||||||
|
<UiIcon name="phone" :size="16" />
|
||||||
|
از دفترچه تلفن
|
||||||
|
</UiButton>
|
||||||
|
<UiButton variant="ghost" block :disabled="pickingContact" @click="pickFromVcf">
|
||||||
|
از فایل .vcf
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<UiButton variant="ghost" @click="closeSource">انصراف</UiButton>
|
||||||
|
</template>
|
||||||
|
</UiModal>
|
||||||
|
|
||||||
|
<UiModal nest :open="pickOpen" title="انتخاب مخاطب" @close="closePick">
|
||||||
|
<p class="pick-lead">
|
||||||
|
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتبشده الفبایی).
|
||||||
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
|
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
|
||||||
</p>
|
</p>
|
||||||
<div class="pick-search">
|
<div class="pick-search">
|
||||||
@@ -561,6 +600,11 @@ onMounted(load);
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pick-choose {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pick-search {
|
.pick-search {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user