283 lines
10 KiB
Vue
283 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||
import {
|
||
CreateMyDeliveryAddressDocument,
|
||
DeleteMyDeliveryAddressDocument,
|
||
MyDeliveryAddressesDocument,
|
||
SetMyDefaultDeliveryAddressDocument,
|
||
type MyDeliveryAddressesQuery,
|
||
} from '~/composables/graphql/generated';
|
||
|
||
type AddressSuggestion = {
|
||
value: string;
|
||
unrestricted_value?: string;
|
||
data?: {
|
||
fias_id?: string;
|
||
};
|
||
};
|
||
|
||
type DeliveryAddressItem = MyDeliveryAddressesQuery['myDeliveryAddresses'][number];
|
||
|
||
const addressFeedback = ref('');
|
||
const addressFeedbackTone = ref<'success' | 'error'>('success');
|
||
|
||
const deliveryAddressesQuery = useQuery(MyDeliveryAddressesDocument);
|
||
const createAddressMutation = useMutation(CreateMyDeliveryAddressDocument, { throws: 'never' });
|
||
const setDefaultAddressMutation = useMutation(SetMyDefaultDeliveryAddressDocument, { throws: 'never' });
|
||
const deleteAddressMutation = useMutation(DeleteMyDeliveryAddressDocument, { throws: 'never' });
|
||
|
||
const addressForm = reactive({
|
||
label: '',
|
||
address: '',
|
||
unrestrictedValue: '',
|
||
fiasId: '',
|
||
});
|
||
const addressSearch = ref('');
|
||
const addressSuggestions = ref<AddressSuggestion[]>([]);
|
||
const addressLoading = ref(false);
|
||
const addressOpen = ref(false);
|
||
const addressSearchTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||
const addressBusyId = ref<string | null>(null);
|
||
const addressDropdownRef = ref<HTMLElement | null>(null);
|
||
|
||
const deliveryAddresses = computed<DeliveryAddressItem[]>(() => deliveryAddressesQuery.result.value?.myDeliveryAddresses ?? []);
|
||
|
||
function clearAddressTimer() {
|
||
if (!addressSearchTimer.value) {
|
||
return;
|
||
}
|
||
clearTimeout(addressSearchTimer.value);
|
||
addressSearchTimer.value = null;
|
||
}
|
||
|
||
async function fetchAddressSuggestions() {
|
||
const query = addressSearch.value.trim();
|
||
if (query.length < 2) {
|
||
addressSuggestions.value = [];
|
||
addressOpen.value = false;
|
||
return;
|
||
}
|
||
|
||
addressLoading.value = true;
|
||
await $fetch<{ suggestions: AddressSuggestion[] }>('/api/dadata/address', {
|
||
method: 'POST',
|
||
body: { query },
|
||
})
|
||
.then((response) => {
|
||
addressSuggestions.value = response.suggestions || [];
|
||
addressOpen.value = addressSuggestions.value.length > 0;
|
||
})
|
||
.finally(() => {
|
||
addressLoading.value = false;
|
||
});
|
||
}
|
||
|
||
function scheduleAddressSuggest() {
|
||
clearAddressTimer();
|
||
addressSearchTimer.value = setTimeout(() => {
|
||
void fetchAddressSuggestions();
|
||
}, 250);
|
||
}
|
||
|
||
function applyAddressSuggestion(item: AddressSuggestion) {
|
||
addressOpen.value = false;
|
||
addressSearch.value = item.value;
|
||
addressForm.address = item.value;
|
||
addressForm.unrestrictedValue = item.unrestricted_value || item.value;
|
||
addressForm.fiasId = item.data?.fias_id || '';
|
||
}
|
||
|
||
function closeDropdownsFromOutside(event: MouseEvent) {
|
||
const target = event.target as Node | null;
|
||
if (addressDropdownRef.value && target && !addressDropdownRef.value.contains(target)) {
|
||
addressOpen.value = false;
|
||
}
|
||
}
|
||
|
||
async function addDeliveryAddress() {
|
||
addressFeedback.value = '';
|
||
|
||
const normalizedAddress = addressForm.address.trim() || addressSearch.value.trim();
|
||
if (normalizedAddress.length < 5) {
|
||
addressFeedbackTone.value = 'error';
|
||
addressFeedback.value = 'Введите адрес через подсказки DaData.';
|
||
return;
|
||
}
|
||
|
||
const result = await createAddressMutation.mutate({
|
||
input: {
|
||
label: addressForm.label.trim() ? addressForm.label.trim() : null,
|
||
address: normalizedAddress,
|
||
unrestrictedValue: addressForm.unrestrictedValue.trim() ? addressForm.unrestrictedValue.trim() : null,
|
||
fiasId: addressForm.fiasId.trim() ? addressForm.fiasId.trim() : null,
|
||
},
|
||
});
|
||
|
||
const payload = result?.data?.createMyDeliveryAddress;
|
||
if (!payload) {
|
||
addressFeedbackTone.value = 'error';
|
||
addressFeedback.value = createAddressMutation.error.value?.message || 'Не удалось добавить адрес.';
|
||
return;
|
||
}
|
||
|
||
addressForm.label = '';
|
||
addressForm.address = '';
|
||
addressForm.unrestrictedValue = '';
|
||
addressForm.fiasId = '';
|
||
addressSearch.value = '';
|
||
addressSuggestions.value = [];
|
||
addressOpen.value = false;
|
||
|
||
addressFeedbackTone.value = 'success';
|
||
addressFeedback.value = 'Адрес сохранён.';
|
||
await deliveryAddressesQuery.refetch();
|
||
}
|
||
|
||
async function setDefaultAddress(addressId: string) {
|
||
addressFeedback.value = '';
|
||
addressBusyId.value = addressId;
|
||
|
||
const result = await setDefaultAddressMutation.mutate({ addressId });
|
||
const payload = result?.data?.setMyDefaultDeliveryAddress;
|
||
addressBusyId.value = null;
|
||
|
||
if (!payload) {
|
||
addressFeedbackTone.value = 'error';
|
||
addressFeedback.value = setDefaultAddressMutation.error.value?.message || 'Не удалось сделать адрес основным.';
|
||
return;
|
||
}
|
||
|
||
addressFeedbackTone.value = 'success';
|
||
addressFeedback.value = 'Основной адрес обновлён.';
|
||
await deliveryAddressesQuery.refetch();
|
||
}
|
||
|
||
async function deleteAddress(addressId: string) {
|
||
addressFeedback.value = '';
|
||
addressBusyId.value = addressId;
|
||
|
||
const result = await deleteAddressMutation.mutate({ addressId });
|
||
const payload = result?.data?.deleteMyDeliveryAddress;
|
||
addressBusyId.value = null;
|
||
|
||
if (!payload) {
|
||
addressFeedbackTone.value = 'error';
|
||
addressFeedback.value = deleteAddressMutation.error.value?.message || 'Не удалось удалить адрес.';
|
||
return;
|
||
}
|
||
|
||
addressFeedbackTone.value = 'success';
|
||
addressFeedback.value = 'Адрес удалён.';
|
||
await deliveryAddressesQuery.refetch();
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('click', closeDropdownsFromOutside);
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('click', closeDropdownsFromOutside);
|
||
clearAddressTimer();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<section class="space-y-6">
|
||
<NuxtLink to="/profile" class="link link-hover text-sm">← Назад в профиль</NuxtLink>
|
||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Адреса доставки</h1>
|
||
|
||
<div class="surface-card rounded-3xl p-5">
|
||
<p class="text-sm text-[#355947]">
|
||
Добавьте адрес через DaData и выберите основной. Этот адрес будет использоваться по умолчанию в корзине.
|
||
</p>
|
||
|
||
<fieldset class="fieldset mt-4">
|
||
<legend class="fieldset-legend">Название адреса (необязательно)</legend>
|
||
<input v-model="addressForm.label" type="text" class="input input-bordered w-full" placeholder="Склад МСК" >
|
||
</fieldset>
|
||
|
||
<div ref="addressDropdownRef" class="relative mt-2">
|
||
<fieldset class="fieldset">
|
||
<legend class="fieldset-legend">Поиск адреса (DaData)</legend>
|
||
<input
|
||
v-model="addressSearch"
|
||
type="text"
|
||
class="input input-bordered w-full"
|
||
placeholder="Начните вводить адрес"
|
||
@input="scheduleAddressSuggest"
|
||
@focus="addressOpen = addressSuggestions.length > 0"
|
||
>
|
||
</fieldset>
|
||
|
||
<span v-if="addressLoading" class="loading loading-spinner loading-sm absolute right-3 top-1/2 -translate-y-1/2" />
|
||
|
||
<div
|
||
v-if="addressOpen && addressSuggestions.length > 0"
|
||
class="absolute z-30 mt-2 max-h-72 w-full overflow-auto rounded-box border border-base-300 bg-base-100 p-2 shadow-xl"
|
||
>
|
||
<button
|
||
v-for="item in addressSuggestions"
|
||
:key="`${item.value}-${item.data?.fias_id || ''}`"
|
||
type="button"
|
||
class="btn btn-ghost mb-1 h-auto min-h-0 w-full justify-start whitespace-normal px-3 py-2 text-left"
|
||
@click="applyAddressSuggestion(item)"
|
||
>
|
||
<span class="block text-sm font-semibold">{{ item.value }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="btn btn-primary mt-4 w-full" :disabled="createAddressMutation.loading.value" @click="addDeliveryAddress">
|
||
{{ createAddressMutation.loading.value ? 'Добавляем…' : 'Добавить адрес' }}
|
||
</button>
|
||
|
||
<div v-if="addressFeedback" class="alert mt-3" :class="addressFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
||
{{ addressFeedback }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="surface-card rounded-3xl p-5">
|
||
<h2 class="text-xl font-bold text-[#123824]">Список адресов</h2>
|
||
|
||
<div class="mt-4 space-y-2">
|
||
<div v-if="deliveryAddressesQuery.loading.value" class="alert surface-card border-0">Загрузка адресов...</div>
|
||
<div v-else-if="deliveryAddresses.length === 0" class="alert surface-card border-0">
|
||
Пока нет адресов доставки.
|
||
</div>
|
||
|
||
<article
|
||
v-for="address in deliveryAddresses"
|
||
:key="address.id"
|
||
class="rounded-2xl border border-[#d6ebde] bg-white/75 p-3"
|
||
>
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div>
|
||
<p class="font-semibold text-[#123824]">{{ address.label || 'Адрес доставки' }}</p>
|
||
<p class="text-sm text-[#355947]">{{ address.unrestrictedValue || address.address }}</p>
|
||
</div>
|
||
<span v-if="address.isDefault" class="badge badge-success">Основной</span>
|
||
</div>
|
||
|
||
<div class="mt-3 flex flex-wrap gap-2">
|
||
<button
|
||
v-if="!address.isDefault"
|
||
class="btn btn-sm"
|
||
:disabled="addressBusyId === address.id"
|
||
@click="setDefaultAddress(address.id)"
|
||
>
|
||
{{ addressBusyId === address.id ? 'Сохраняем…' : 'Сделать основным' }}
|
||
</button>
|
||
<button
|
||
class="btn btn-sm btn-ghost text-error"
|
||
:disabled="addressBusyId === address.id"
|
||
@click="deleteAddress(address.id)"
|
||
>
|
||
{{ addressBusyId === address.id ? 'Удаляем…' : 'Удалить' }}
|
||
</button>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|