Simplify profile settings screens

This commit is contained in:
Ruslan Bakiev
2026-04-06 21:37:17 +07:00
parent 5db6474e94
commit 6ed821a295
3 changed files with 304 additions and 259 deletions

View File

@@ -1,138 +1,25 @@
<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 route = useRoute();
const addressFeedback = ref(route.query.created === '1' ? 'Адрес сохранён.' : '');
const addressFeedbackTone = ref<'success' | 'error'>(route.query.created === '1' ? 'success' : 'error');
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;
@@ -170,113 +57,80 @@ async function deleteAddress(addressId: string) {
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 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 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 bg-base-100 p-2"
>
<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 class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="space-y-2">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Адреса доставки</h1>
<p class="text-sm leading-6 text-[#466653]">
Выберите основной адрес для заказов или добавьте новый.
</p>
</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>
<NuxtLink
to="/profile/addresses/new"
class="btn rounded-full border-0 bg-[#123824] px-6 text-white hover:bg-[#0f2f20]"
>
Добавить
</NuxtLink>
</div>
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Список адресов</h2>
<div
v-if="addressFeedback"
class="rounded-[24px] border px-4 py-3 text-sm font-medium"
:class="addressFeedbackTone === 'success'
? 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'
: 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'"
>
{{ addressFeedback }}
</div>
<div class="mt-4 space-y-2">
<div v-if="deliveryAddressesQuery.loading.value" class="alert surface-card">Загрузка адресов...</div>
<div v-else-if="deliveryAddresses.length === 0" class="alert surface-card">
Пока нет адресов доставки.
</div>
<div v-if="deliveryAddressesQuery.loading.value" class="rounded-[28px] bg-white px-5 py-4 text-sm text-[#355947] shadow-[0_18px_38px_rgba(18,56,36,0.08)]">
Загружаем адреса...
</div>
<article
v-for="address in deliveryAddresses"
:key="address.id"
class="rounded-2xl bg-[#f8fbf9] p-3 transition hover:shadow-md"
>
<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 v-else-if="deliveryAddresses.length === 0" class="rounded-[28px] bg-white px-5 py-4 text-sm text-[#355947] shadow-[0_18px_38px_rgba(18,56,36,0.08)]">
Пока нет адресов доставки.
</div>
<div v-else class="space-y-4">
<article
v-for="address in deliveryAddresses"
:key="address.id"
class="rounded-[28px] bg-white px-5 py-4 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<p class="text-lg font-bold text-[#123824]">{{ address.label || 'Адрес доставки' }}</p>
<span v-if="address.isDefault" class="badge border-0 bg-[#e8f5ec] text-[#1c6b45]">Основной</span>
</div>
<span v-if="address.isDefault" class="badge badge-success">Основной</span>
<p class="text-sm leading-6 text-[#557562]">{{ address.unrestrictedValue || address.address }}</p>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2">
<button
v-if="!address.isDefault"
class="btn btn-sm"
class="btn rounded-full border border-[#d7e6dc] bg-white px-5 text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8]"
:disabled="addressBusyId === address.id"
@click="setDefaultAddress(address.id)"
>
{{ addressBusyId === address.id ? 'Сохраняем' : 'Сделать основным' }}
{{ addressBusyId === address.id ? 'Сохраняем...' : 'Сделать основным' }}
</button>
<button
class="btn btn-sm btn-ghost text-error"
class="btn rounded-full border border-[#e5cfc7] bg-[#fff5f1] px-5 text-[#a64d2d] hover:border-[#deb5a8] hover:bg-[#ffe8e0]"
:disabled="addressBusyId === address.id"
@click="deleteAddress(address.id)"
>
{{ addressBusyId === address.id ? 'Удаляем' : 'Удалить' }}
{{ addressBusyId === address.id ? 'Удаляем...' : 'Удалить' }}
</button>
</div>
</article>
</div>
</div>
</article>
</div>
</section>
</template>

View File

@@ -0,0 +1,188 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import { CreateMyDeliveryAddressDocument } from '~/composables/graphql/generated';
type AddressSuggestion = {
value: string;
unrestricted_value?: string;
data?: {
fias_id?: string;
};
};
const addressFeedback = ref('');
const addressLoading = ref(false);
const addressOpen = ref(false);
const addressSearch = ref('');
const addressSuggestions = ref<AddressSuggestion[]>([]);
const addressSearchTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const addressDropdownRef = ref<HTMLElement | null>(null);
const createAddressMutation = useMutation(CreateMyDeliveryAddressDocument, { throws: 'never' });
const addressForm = reactive({
label: '',
address: '',
unrestrictedValue: '',
fiasId: '',
});
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) {
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,
},
});
if (!result?.data?.createMyDeliveryAddress) {
addressFeedback.value = createAddressMutation.error.value?.message || 'Не удалось добавить адрес.';
return;
}
await navigateTo('/profile/addresses?created=1');
}
onMounted(() => {
document.addEventListener('click', closeDropdownsFromOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeDropdownsFromOutside);
clearAddressTimer();
});
</script>
<template>
<section class="space-y-6">
<NuxtLink to="/profile/addresses" class="link link-hover text-sm"> Назад к адресам</NuxtLink>
<div class="space-y-2">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Новый адрес</h1>
<p class="text-sm leading-6 text-[#466653]">
Найдите адрес через DaData и сохраните его в профиль.
</p>
</div>
<div class="rounded-[28px] bg-white px-5 py-5 shadow-[0_18px_38px_rgba(18,56,36,0.08)] md:px-6 md:py-6">
<fieldset class="fieldset">
<legend class="fieldset-legend">Название адреса</legend>
<input v-model="addressForm.label" type="text" class="input w-full" placeholder="Склад МСК">
</fieldset>
<div ref="addressDropdownRef" class="relative mt-4">
<fieldset class="fieldset">
<legend class="fieldset-legend">Адрес</legend>
<input
v-model="addressSearch"
type="text"
class="input 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-[24px] bg-white p-2 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<button
v-for="item in addressSuggestions"
:key="`${item.value}-${item.data?.fias_id || ''}`"
type="button"
class="w-full rounded-[18px] px-3 py-3 text-left transition hover:bg-[#f6fbf8]"
@click="applyAddressSuggestion(item)"
>
<span class="block text-sm font-semibold text-[#123824]">{{ item.value }}</span>
</button>
</div>
</div>
<div
v-if="addressFeedback"
class="mt-4 rounded-[20px] border border-[#f1d1c7] bg-[#fff3ef] px-4 py-3 text-sm font-medium text-[#9d4426]"
>
{{ addressFeedback }}
</div>
<div class="mt-6 flex flex-col gap-3 md:flex-row">
<button
class="btn rounded-full border-0 bg-[#123824] px-6 text-white hover:bg-[#0f2f20]"
:disabled="createAddressMutation.loading.value"
@click="addDeliveryAddress"
>
{{ createAddressMutation.loading.value ? 'Сохраняем...' : 'Сохранить адрес' }}
</button>
<NuxtLink to="/profile/addresses" class="btn rounded-full border border-[#d7e6dc] bg-white px-6 text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8]">
Отмена
</NuxtLink>
</div>
</div>
</section>
</template>

View File

@@ -153,78 +153,81 @@ async function removeConnection(connectionId: string) {
{{ feedback }}
</div>
<div class="rounded-[32px] bg-[#edf3ee] p-6 md:p-8">
<div
v-if="activeConnections.length > 0"
class="space-y-4"
<div
v-if="activeConnections.length > 0"
class="space-y-4"
>
<article
v-for="{ option, connection } in activeConnections"
:key="connection!.id"
class="rounded-[28px] bg-white px-5 py-4 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<article
v-for="{ option, connection } in activeConnections"
:key="connection!.id"
class="rounded-[28px] bg-white px-5 py-4 shadow-[0_18px_38px_rgba(18,56,36,0.08)]"
>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex min-w-0 items-center gap-4">
<div v-if="messengerConnectionAvatarSrc(connection)" class="avatar">
<div class="h-14 w-14 rounded-[20px]">
<img :src="messengerConnectionAvatarSrc(connection)" :alt="messengerConnectionName(connection)">
</div>
</div>
<div
v-else
class="flex h-14 w-14 items-center justify-center rounded-[20px] text-sm font-black"
:class="option.iconClass"
>
{{ messengerConnectionInitials(connection, option.channel === 'TELEGRAM' ? 'TG' : 'MX') }}
</div>
<div class="min-w-0">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">{{ option.label }}</p>
<p class="truncate text-lg font-bold text-[#123824]">{{ messengerConnectionName(connection) }}</p>
<p class="truncate text-sm text-[#557562]">{{ messengerConnectionHandle(connection) || connection.channelId }}</p>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex min-w-0 items-center gap-4">
<div v-if="messengerConnectionAvatarSrc(connection)" class="avatar">
<div class="h-14 w-14 rounded-[20px]">
<img :src="messengerConnectionAvatarSrc(connection)" :alt="messengerConnectionName(connection)">
</div>
</div>
<button
class="btn rounded-full border border-[#e5cfc7] bg-[#fff5f1] px-5 text-[#a64d2d] hover:border-[#deb5a8] hover:bg-[#ffe8e0]"
:disabled="deleteConnectionMutation.loading.value"
@click="removeConnection(connection!.id)"
<div
v-else
class="flex h-14 w-14 items-center justify-center rounded-[20px] text-sm font-black"
:class="option.iconClass"
>
{{ deleteConnectionMutation.loading.value ? 'Отключаем...' : 'Отключить' }}
</button>
</div>
</article>
</div>
{{ messengerConnectionInitials(connection, option.channel === 'TELEGRAM' ? 'TG' : 'MX') }}
</div>
<p
v-else
class="text-sm leading-6 text-[#557562]"
>
Пока ничего не подключено.
</p>
<div
v-if="availableOptions.length > 0"
class="mt-6 grid gap-4 md:grid-cols-2"
>
<button
v-for="option in availableOptions"
:key="option.channel"
class="flex min-h-[104px] flex-col items-start justify-between rounded-[28px] border-0 px-5 py-5 text-left shadow-[0_18px_38px_rgba(18,56,36,0.08)] transition"
:class="[option.buttonClass, { 'opacity-60': !connectUrl(option.channel) }]"
:disabled="pendingChannel === option.channel || !connectUrl(option.channel)"
@click="connectMessenger(option.channel)"
>
<div class="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-white/18 text-sm font-black text-white">
{{ option.channel === 'TELEGRAM' ? 'TG' : 'MX' }}
<div class="min-w-0">
<p class="truncate text-lg font-bold text-[#123824]">{{ messengerConnectionName(connection) }}</p>
<p class="truncate text-sm text-[#557562]">{{ messengerConnectionHandle(connection) || connection.channelId }}</p>
</div>
</div>
<p class="text-sm font-semibold text-white">
<button
class="btn rounded-full border border-[#e5cfc7] bg-[#fff5f1] px-5 text-[#a64d2d] hover:border-[#deb5a8] hover:bg-[#ffe8e0]"
:disabled="deleteConnectionMutation.loading.value"
@click="removeConnection(connection!.id)"
>
{{ deleteConnectionMutation.loading.value ? 'Отключаем...' : 'Отключить' }}
</button>
</div>
</article>
</div>
<p
v-else
class="text-sm leading-6 text-[#557562]"
>
Пока ничего не подключено.
</p>
<div
v-if="availableOptions.length > 0"
class="space-y-4"
>
<button
v-for="option in availableOptions"
:key="option.channel"
class="flex w-full items-center justify-between rounded-[28px] border-0 px-5 py-5 text-left shadow-[0_18px_38px_rgba(18,56,36,0.08)] transition"
:class="[option.buttonClass, { 'opacity-60': !connectUrl(option.channel) }]"
:disabled="pendingChannel === option.channel || !connectUrl(option.channel)"
@click="connectMessenger(option.channel)"
>
<div class="flex items-center gap-4">
<div
class="inline-flex h-11 min-w-11 items-center justify-center rounded-2xl bg-white text-sm font-black"
:class="option.channel === 'TELEGRAM' ? 'text-[#1a9c63]' : 'text-[#2b7fff]'"
>
{{ option.channel === 'TELEGRAM' ? 'TG' : 'MAX' }}
</div>
<p class="text-base font-semibold text-white">
{{ pendingChannel === option.channel ? `Открываем ${option.label}...` : `Подключить ${option.label}` }}
</p>
</button>
</div>
</div>
</button>
<div class="mt-4 space-y-2">
<div class="space-y-2">
<p
v-for="option in availableOptions.filter((item) => !connectUrl(item.channel))"
:key="`${option.channel}-hint`"