Redesign messenger connection settings

This commit is contained in:
Ruslan Bakiev
2026-04-06 15:51:10 +07:00
parent 824065f852
commit 17b5a87699
6 changed files with 293 additions and 82 deletions

View File

@@ -282,6 +282,7 @@ export type Mutation = {
createMyDeliveryAddress: DeliveryAddress;
createReferral: ReferralLink;
deleteMyDeliveryAddress: Scalars['Boolean']['output'];
deleteMyMessengerConnection: Scalars['Boolean']['output'];
managerSetOrderOffer: Order;
managerSetOrderStatus: Order;
registerSelf: RegistrationRequest;
@@ -352,6 +353,11 @@ export type MutationDeleteMyDeliveryAddressArgs = {
};
export type MutationDeleteMyMessengerConnectionArgs = {
connectionId: Scalars['ID']['input'];
};
export type MutationManagerSetOrderOfferArgs = {
input: SetOrderOfferInput;
};
@@ -1043,6 +1049,13 @@ export type DeleteMyDeliveryAddressMutationVariables = Exact<{
export type DeleteMyDeliveryAddressMutation = { __typename?: 'Mutation', deleteMyDeliveryAddress: boolean };
export type DeleteMyMessengerConnectionMutationVariables = Exact<{
connectionId: Scalars['ID']['input'];
}>;
export type DeleteMyMessengerConnectionMutation = { __typename?: 'Mutation', deleteMyMessengerConnection: boolean };
export type MyCounterpartyProfileQueryVariables = Exact<{ [key: string]: never; }>;
@@ -2650,6 +2663,33 @@ export function useDeleteMyDeliveryAddressMutation(options: VueApolloComposable.
return VueApolloComposable.useMutation<DeleteMyDeliveryAddressMutation, DeleteMyDeliveryAddressMutationVariables>(DeleteMyDeliveryAddressDocument, options);
}
export type DeleteMyDeliveryAddressMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<DeleteMyDeliveryAddressMutation, DeleteMyDeliveryAddressMutationVariables>;
export const DeleteMyMessengerConnectionDocument = gql`
mutation DeleteMyMessengerConnection($connectionId: ID!) {
deleteMyMessengerConnection(connectionId: $connectionId)
}
`;
/**
* __useDeleteMyMessengerConnectionMutation__
*
* To run a mutation, you first call `useDeleteMyMessengerConnectionMutation` within a Vue component and pass it any options that fit your needs.
* When your component renders, `useDeleteMyMessengerConnectionMutation` returns an object that includes:
* - A mutate function that you can call at any time to execute the mutation
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
*
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
*
* @example
* const { mutate, loading, error, onDone } = useDeleteMyMessengerConnectionMutation({
* variables: {
* connectionId: // value for 'connectionId'
* },
* });
*/
export function useDeleteMyMessengerConnectionMutation(options: VueApolloComposable.UseMutationOptions<DeleteMyMessengerConnectionMutation, DeleteMyMessengerConnectionMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<DeleteMyMessengerConnectionMutation, DeleteMyMessengerConnectionMutationVariables>> = {}) {
return VueApolloComposable.useMutation<DeleteMyMessengerConnectionMutation, DeleteMyMessengerConnectionMutationVariables>(DeleteMyMessengerConnectionDocument, options);
}
export type DeleteMyMessengerConnectionMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<DeleteMyMessengerConnectionMutation, DeleteMyMessengerConnectionMutationVariables>;
export const MyCounterpartyProfileDocument = gql`
query MyCounterpartyProfile {
myCounterpartyProfile {

View File

@@ -18,6 +18,7 @@ type MessengerStartInput = {
export function useMessengerStart() {
const pendingChannel = ref<MessengerChannel | null>(null);
const maxMiniApp = useMaxMiniApp();
async function openMessengerBot({ channel, baseUrl, email, redirectPath }: MessengerStartInput) {
pendingChannel.value = channel;
@@ -38,7 +39,17 @@ export function useMessengerStart() {
const startUrl = buildMessengerBotStartUrl(baseUrl, payload.startToken);
if (import.meta.client) {
window.open(startUrl, '_blank', 'noopener,noreferrer');
if (
channel === 'MAX'
&& maxMiniApp.isAvailable.value
&& startUrl.startsWith('https://max.ru/')
&& typeof maxMiniApp.webApp.value?.openMaxLink === 'function'
) {
maxMiniApp.webApp.value.openMaxLink(startUrl);
}
else {
window.open(startUrl, '_blank', 'noopener,noreferrer');
}
}
return payload;

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
DeleteMyMessengerConnectionDocument,
MeDocument,
MyMessengerConnectionsDocument,
} from '~/composables/graphql/generated';
@@ -12,9 +13,11 @@ import {
} from '~/composables/useMessengerConnectionPresentation';
import { useMessengerStart } from '~/composables/useMessengerStart';
type MessengerChannel = 'TELEGRAM' | 'MAX';
type MessengerItem = {
id: string;
type: 'TELEGRAM' | 'MAX';
type: MessengerChannel;
isActive: boolean;
channelId: string;
displayName?: string | null;
@@ -22,23 +25,56 @@ type MessengerItem = {
avatarAvailable?: boolean | null;
};
type MessengerOption = {
channel: MessengerChannel;
label: string;
title: string;
description: string;
buttonClass: string;
iconClass: string;
unavailableText: string;
};
const config = useRuntimeConfig();
const feedback = ref('');
const meQuery = useQuery(MeDocument);
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
const deleteConnectionMutation = useMutation(DeleteMyMessengerConnectionDocument);
const { openMessengerBot, pendingChannel } = useMessengerStart();
const telegramConnection = computed(() =>
connectionsQuery.result.value?.myMessengerConnections?.find(
(item: MessengerItem) => item.type === 'TELEGRAM' && item.isActive,
),
) ?? null,
);
const maxConnection = computed(() =>
connectionsQuery.result.value?.myMessengerConnections?.find(
(item: MessengerItem) => item.type === 'MAX' && item.isActive,
),
) ?? null,
);
const messengerOptions: MessengerOption[] = [
{
channel: 'TELEGRAM',
label: 'Telegram',
title: 'Подключить Telegram',
description: 'Получайте статусы заказов и сервисные уведомления в Telegram.',
buttonClass: 'bg-[#1a9c63] text-white hover:bg-[#148553]',
iconClass: 'bg-[#123824] text-white',
unavailableText: 'Telegram пока не настроен в окружении фронта.',
},
{
channel: 'MAX',
label: 'MAX',
title: 'Подключить MAX',
description: 'Открывает MAX-бота и привязывает аккаунт к личному кабинету.',
buttonClass: 'bg-[#2b7fff] text-white hover:bg-[#1d6df1]',
iconClass: 'bg-[#2b7fff] text-white',
unavailableText: 'MAX пока не настроен в окружении фронта.',
},
];
function buildBotConnectUrl(baseUrl: string) {
const accountEmail = meQuery.result.value?.me?.email?.trim().toLowerCase();
if (!accountEmail || !baseUrl) {
@@ -51,9 +87,32 @@ function buildBotConnectUrl(baseUrl: string) {
const telegramConnectUrl = computed(() => buildBotConnectUrl(config.public.telegramBotUrl || ''));
const maxConnectUrl = computed(() => buildBotConnectUrl(config.public.maxBotUrl || ''));
async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
const baseUrl = channel === 'TELEGRAM' ? telegramConnectUrl.value : maxConnectUrl.value;
function connectUrl(channel: MessengerChannel) {
return channel === 'TELEGRAM' ? telegramConnectUrl.value : maxConnectUrl.value;
}
function connectionFor(channel: MessengerChannel) {
return channel === 'TELEGRAM' ? telegramConnection.value : maxConnection.value;
}
const activeConnections = computed(() => messengerOptions
.map((option) => ({
option,
connection: connectionFor(option.channel),
}))
.filter((item) => Boolean(item.connection)));
const availableOptions = computed(() => messengerOptions
.filter((option) => !connectionFor(option.channel)));
async function connectMessenger(channel: MessengerChannel) {
feedback.value = '';
const baseUrl = connectUrl(channel);
if (!baseUrl) {
feedback.value = channel === 'MAX'
? 'MAX не откроется, пока не задан NUXT_PUBLIC_MAX_BOT_URL.'
: 'Telegram не откроется, пока не задан NUXT_PUBLIC_TELEGRAM_BOT_URL.';
return;
}
@@ -63,92 +122,189 @@ async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
redirectPath: `/profile/notifications/success?connected=${channel.toLowerCase()}`,
});
}
async function removeConnection(connectionId: string) {
feedback.value = '';
const result = await deleteConnectionMutation.mutate({
connectionId,
});
if (!result?.data?.deleteMyMessengerConnection) {
feedback.value = 'Не удалось отключить аккаунт. Попробуйте еще раз.';
return;
}
await connectionsQuery.refetch();
}
</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]">
Подключите Telegram и Max, чтобы получать статусы заказов и важные уведомления в удобном канале.
<div class="space-y-2">
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Уведомления</h1>
<p class="max-w-3xl text-sm leading-6 text-[#466653]">
Подключите удобные мессенджеры, чтобы получать статусы заказов и важные уведомления без лишних переходов в кабинет.
</p>
</div>
<div class="mt-4 space-y-3">
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
<p class="font-semibold">Telegram</p>
<div v-if="telegramConnection" class="mt-3 flex items-center gap-3 rounded-2xl bg-white px-3 py-2">
<div v-if="messengerConnectionAvatarSrc(telegramConnection)" class="avatar">
<div class="h-11 w-11 rounded-full">
<img :src="messengerConnectionAvatarSrc(telegramConnection)" :alt="messengerConnectionName(telegramConnection)">
</div>
<div
v-if="feedback"
class="rounded-[24px] border px-4 py-3 text-sm font-medium"
:class="feedback.includes('Не удалось') || feedback.includes('не откроется')
? 'border-[#f1d1c7] bg-[#fff3ef] text-[#9d4426]'
: 'border-[#cbe9d6] bg-[#f1fbf5] text-[#1c6b45]'"
>
{{ feedback }}
</div>
<div
v-if="activeConnections.length === 0"
class="rounded-[32px] bg-[#edf3ee] p-6 md:p-8"
>
<div class="space-y-3">
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">Подключите мессенджеры</h2>
<p class="max-w-3xl text-sm leading-6 text-[#557562]">
Вы можете подключить любой из мессенджеров ниже. После подключения уведомления о заказах и важных действиях будут приходить прямо туда.
</p>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2">
<button
v-for="option in messengerOptions"
:key="option.channel"
class="flex min-h-[120px] 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="space-y-2">
<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>
<div v-else class="avatar placeholder">
<div class="h-11 w-11 rounded-full bg-[#123824] text-sm font-bold text-white">
<span>{{ messengerConnectionInitials(telegramConnection, 'TG') }}</span>
</div>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#123824]">
{{ messengerConnectionName(telegramConnection) }}
</p>
<p class="truncate text-xs text-[#5c7b69]">
{{ messengerConnectionHandle(telegramConnection) || 'Подключен' }}
</p>
<div>
<p class="text-lg font-black tracking-[-0.03em]">{{ option.title }}</p>
<p class="mt-1 text-sm text-white/82">{{ option.description }}</p>
</div>
</div>
<p v-else class="text-sm opacity-80">Не подключен</p>
<button
class="btn btn-secondary mt-3 w-full"
:class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }"
:disabled="pendingChannel === 'TELEGRAM' || !telegramConnectUrl"
@click="connectMessenger('TELEGRAM')"
>
{{
pendingChannel === 'TELEGRAM'
? 'Открываем Telegram…'
: telegramConnection
? 'Переподключить Telegram'
: 'Подключить Telegram'
}}
</button>
</div>
<p class="text-sm font-semibold text-white/90">
{{ pendingChannel === option.channel ? `Открываем ${option.label}...` : `Перейти в ${option.label}` }}
</p>
</button>
</div>
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
<p class="font-semibold">Max</p>
<div v-if="maxConnection" class="mt-3 flex items-center gap-3 rounded-2xl bg-white px-3 py-2">
<div class="avatar placeholder">
<div class="h-11 w-11 rounded-full bg-[#2b7fff] text-sm font-bold text-white">
<span>{{ messengerConnectionInitials(maxConnection, 'MX') }}</span>
</div>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#123824]">
{{ messengerConnectionName(maxConnection) }}
</p>
<p class="truncate text-xs text-[#5c7b69]">
{{ messengerConnectionHandle(maxConnection) || 'Подключен' }}
</p>
</div>
</div>
<p v-else class="text-sm opacity-80">Не подключен</p>
<button
class="btn btn-accent mt-3 w-full"
:class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }"
:disabled="pendingChannel === 'MAX' || !maxConnectUrl"
@click="connectMessenger('MAX')"
>
{{
pendingChannel === 'MAX'
? 'Открываем Max…'
: maxConnection
? 'Переподключить Max'
: 'Подключить Max'
}}
</button>
</div>
<div class="mt-4 space-y-2">
<p
v-for="option in messengerOptions.filter((item) => !connectUrl(item.channel))"
:key="`${option.channel}-hint`"
class="text-sm text-[#8b5a49]"
>
{{ option.unavailableText }}
</p>
</div>
</div>
<template v-else>
<div class="rounded-[32px] bg-[#edf3ee] p-6 md:p-8">
<div class="space-y-3">
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">Подключенные аккаунты</h2>
<p class="text-sm leading-6 text-[#557562]">
Здесь показаны активные мессенджеры, привязанные к вашему кабинету.
</p>
</div>
<div class="mt-6 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)]"
>
<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>
</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)"
>
{{ deleteConnectionMutation.loading.value ? 'Удаляем...' : 'Удалить' }}
</button>
</div>
</article>
</div>
</div>
<div
v-if="availableOptions.length > 0"
class="rounded-[32px] bg-[#edf3ee] p-6 md:p-8"
>
<div class="space-y-3">
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">Можно подключить еще</h2>
<p class="text-sm leading-6 text-[#557562]">
Добавьте второй канал, чтобы не потерять уведомления, если один мессенджер недоступен.
</p>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2">
<button
v-for="option in availableOptions"
:key="option.channel"
class="flex min-h-[112px] 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>
<p class="text-lg font-black tracking-[-0.03em]">{{ option.title }}</p>
<p class="mt-2 text-sm text-white/82">{{ option.description }}</p>
</div>
<p class="text-sm font-semibold text-white/90">
{{ pendingChannel === option.channel ? `Открываем ${option.label}...` : `Подключить ${option.label}` }}
</p>
</button>
</div>
<div class="mt-4 space-y-2">
<p
v-for="option in availableOptions.filter((item) => !connectUrl(item.channel))"
:key="`${option.channel}-connected-hint`"
class="text-sm text-[#8b5a49]"
>
{{ option.unavailableText }}
</p>
</div>
</div>
<div
v-else
class="rounded-[32px] bg-[linear-gradient(135deg,#123824_0%,#1a5635_100%)] p-6 text-white md:p-8"
>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/65">Готово</p>
<h2 class="mt-3 text-2xl font-black tracking-[-0.03em]">Оба канала подключены</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-white/78">
Теперь важные уведомления будут доступны и в Telegram, и в MAX. Если захотите сменить аккаунт, удалите текущий и подключите новый.
</p>
</div>
</template>
</section>
</template>

View File

@@ -48,12 +48,12 @@ const successConnection = computed(() =>
const profileName = computed(() => meQuery.result.value?.me?.fullName?.trim() || meQuery.result.value?.me?.email || 'Пользователь');
const successTitle = computed(() =>
connectedChannel.value === 'telegram' ? 'Telegram успешно подключен' : 'Канал успешно подключен',
connectedChannel.value === 'telegram' ? 'Telegram успешно подключен' : 'MAX успешно подключен',
);
const successText = computed(() =>
connectedChannel.value === 'telegram'
? 'Теперь этот Telegram привязан к вашему личному кабинету. Все важные уведомления и статусы заказов будут приходить сюда.'
: 'Канал успешно привязан к вашему личному кабинету.',
: 'Теперь этот MAX привязан к вашему личному кабинету. Все важные уведомления и статусы заказов будут приходить сюда.',
);
const successAvatarSrc = computed(() => messengerConnectionAvatarSrc(successConnection.value));
const successAvatarInitials = computed(() =>

View File

@@ -0,0 +1,3 @@
mutation DeleteMyMessengerConnection($connectionId: ID!) {
deleteMyMessengerConnection(connectionId: $connectionId)
}

View File

@@ -523,6 +523,7 @@ type Mutation {
createInvitation(input: CreateInvitationInput!): Invitation!
acceptInvitation(input: AcceptInvitationInput!): User!
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
deleteMyMessengerConnection(connectionId: ID!): Boolean!
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
addProductToCart(productId: ID!): Cart!
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!