feat(profile): show real telegram avatars in messenger chips

This commit is contained in:
Ruslan Bakiev
2026-04-03 18:36:25 +07:00
parent f2fb64a0b7
commit f941ba7192
9 changed files with 267 additions and 51 deletions

View File

@@ -152,11 +152,14 @@ export enum LoginChannel {
export type MessengerConnection = {
__typename?: 'MessengerConnection';
avatarAvailable: Scalars['Boolean']['output'];
channelId: Scalars['String']['output'];
displayName?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
isActive: Scalars['Boolean']['output'];
type: MessengerType;
userId: Scalars['ID']['output'];
username?: Maybe<Scalars['String']['output']>;
};
export type MessengerDispatchResult = {
@@ -619,7 +622,7 @@ export type ConsumeLoginTokenMutation = { __typename?: 'Mutation', consumeLoginT
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
export type MeQuery = { __typename?: 'Query', me?: { __typename?: 'User', id: string, email: string } | null };
export type MeQuery = { __typename?: 'Query', me?: { __typename?: 'User', id: string, email: string, fullName: string } | null };
export type RegisterSelfMutationVariables = Exact<{
input: RegisterSelfInput;
@@ -650,7 +653,7 @@ export type ClientProductsQuery = { __typename?: 'Query', clientProducts: Array<
export type MyMessengerConnectionsQueryVariables = Exact<{ [key: string]: never; }>;
export type MyMessengerConnectionsQuery = { __typename?: 'Query', myMessengerConnections: Array<{ __typename?: 'MessengerConnection', id: string, type: MessengerType, channelId: string, isActive: boolean }> };
export type MyMessengerConnectionsQuery = { __typename?: 'Query', myMessengerConnections: Array<{ __typename?: 'MessengerConnection', id: string, type: MessengerType, channelId: string, displayName?: string | null, username?: string | null, avatarAvailable: boolean, isActive: boolean }> };
export type MyNotificationHistoryQueryVariables = Exact<{
channel: MessengerType;
@@ -791,6 +794,7 @@ export const MeDocument = gql`
me {
id
email
fullName
}
}
`;
@@ -970,6 +974,9 @@ export const MyMessengerConnectionsDocument = gql`
id
type
channelId
displayName
username
avatarAvailable
isActive
}
}

View File

@@ -0,0 +1,51 @@
type MessengerConnectionView = {
id: string;
type: 'TELEGRAM' | 'MAX';
channelId: string;
displayName?: string | null;
username?: string | null;
avatarAvailable?: boolean | null;
};
export function messengerConnectionName(connection: MessengerConnectionView | null | undefined) {
const displayName = String(connection?.displayName || '').trim();
if (displayName) {
return displayName;
}
const username = String(connection?.username || '').trim();
if (username) {
return `@${username.replace(/^@+/, '')}`;
}
return connection?.channelId || 'Не подключен';
}
export function messengerConnectionHandle(connection: MessengerConnectionView | null | undefined) {
const username = String(connection?.username || '').trim().replace(/^@+/, '');
if (username) {
return `@${username}`;
}
return connection?.channelId || '';
}
export function messengerConnectionInitials(connection: MessengerConnectionView | null | undefined, fallback: string) {
const base = messengerConnectionName(connection);
const initials = base
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('');
return initials || fallback;
}
export function messengerConnectionAvatarSrc(connection: MessengerConnectionView | null | undefined) {
if (!connection?.avatarAvailable || connection.type !== 'TELEGRAM') {
return '';
}
return `/api/messenger-avatar/${encodeURIComponent(connection.id)}`;
}

View File

@@ -6,6 +6,12 @@ import {
MyNotificationHistoryDocument,
SendTestMessengerMessageDocument,
} from '~/composables/graphql/generated';
import {
messengerConnectionAvatarSrc,
messengerConnectionHandle,
messengerConnectionInitials,
messengerConnectionName,
} from '~/composables/useMessengerConnectionPresentation';
import { useMessengerStart } from '~/composables/useMessengerStart';
const selectedChannel = ref<'TELEGRAM' | 'MAX'>('TELEGRAM');
@@ -117,15 +123,29 @@ async function sendTest() {
<div class="rounded-2xl border border-[#d6ebde] bg-white/75 p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div class="min-w-0">
<p class="font-semibold">Telegram</p>
<p class="text-sm opacity-80">
{{
telegramConnection
? `Подключен: ${telegramConnection.channelId}`
: 'Не подключен'
}}
</p>
<div v-if="telegramConnection" class="mt-3 flex items-center gap-3">
<div v-if="messengerConnectionAvatarSrc(telegramConnection)" class="avatar">
<div class="h-11 w-11 rounded-full">
<img :src="messengerConnectionAvatarSrc(telegramConnection)" :alt="messengerConnectionName(telegramConnection)">
</div>
</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>
</div>
<p v-else class="text-sm opacity-80">Не подключен</p>
</div>
<button
@@ -147,15 +167,24 @@ async function sendTest() {
<div class="rounded-2xl border border-[#d6ebde] bg-white/75 p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div class="min-w-0">
<p class="font-semibold">Max</p>
<p class="text-sm opacity-80">
{{
maxConnection
? `Подключен: ${maxConnection.channelId}`
: 'Не подключен'
}}
</p>
<div v-if="maxConnection" class="mt-3 flex items-center gap-3">
<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>
</div>
<button

View File

@@ -6,10 +6,21 @@ import {
MyMessengerConnectionsDocument,
type MyDeliveryAddressesQuery,
} from '~/composables/graphql/generated';
import {
messengerConnectionAvatarSrc,
messengerConnectionHandle,
messengerConnectionInitials,
messengerConnectionName,
} from '~/composables/useMessengerConnectionPresentation';
import { isCounterpartyProfileComplete } from '~/composables/useCounterpartyProfile';
type MessengerItem = {
id: string;
type: 'TELEGRAM' | 'MAX';
channelId: string;
displayName?: string | null;
username?: string | null;
avatarAvailable?: boolean | null;
isActive: boolean;
};
@@ -22,22 +33,18 @@ const deliveryAddressesQuery = useQuery(MyDeliveryAddressesDocument);
const profileIsComplete = computed(() => isCounterpartyProfileComplete(profileQuery.result.value?.myCounterpartyProfile));
const telegramConnected = computed(() =>
Boolean(
connectionsQuery.result.value?.myMessengerConnections?.find(
(item: MessengerItem) => item.type === 'TELEGRAM' && item.isActive,
),
connectionsQuery.result.value?.myMessengerConnections?.find(
(item: MessengerItem) => item.type === 'TELEGRAM' && item.isActive,
),
);
const maxConnected = computed(() =>
Boolean(
connectionsQuery.result.value?.myMessengerConnections?.find(
(item: MessengerItem) => item.type === 'MAX' && item.isActive,
),
connectionsQuery.result.value?.myMessengerConnections?.find(
(item: MessengerItem) => item.type === 'MAX' && item.isActive,
),
);
const connectedMessengerCount = computed(() => Number(telegramConnected.value) + Number(maxConnected.value));
const connectedMessengerCount = computed(() => Number(Boolean(telegramConnected.value)) + Number(Boolean(maxConnected.value)));
const companyNamePreview = computed(() => profileQuery.result.value?.myCounterpartyProfile?.companyName?.trim() || '');
const notificationsSummary = computed(() => {
@@ -81,19 +88,43 @@ const defaultDeliveryAddress = computed(() => deliveryAddresses.value.find((item
<div class="mb-2 flex items-center justify-between gap-2">
<p class="text-lg font-bold text-[#123824]">Уведомления</p>
</div>
<div class="mb-2 flex items-center justify-end gap-2">
<span
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold"
:class="telegramConnected ? 'bg-[#def6ea] text-[#0d854a]' : 'bg-[#eceff3] text-[#6b7280]'"
>
TG
</span>
<span
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold"
:class="maxConnected ? 'bg-[#def6ea] text-[#0d854a]' : 'bg-[#eceff3] text-[#6b7280]'"
>
MX
</span>
<div class="mb-3 space-y-2">
<div v-if="telegramConnected" class="flex items-center gap-3 rounded-2xl bg-[#f8fbf9] px-3 py-2">
<div v-if="messengerConnectionAvatarSrc(telegramConnected)" class="avatar">
<div class="h-10 w-10 rounded-full">
<img :src="messengerConnectionAvatarSrc(telegramConnected)" :alt="messengerConnectionName(telegramConnected)">
</div>
</div>
<div v-else class="avatar placeholder">
<div class="h-10 w-10 rounded-full bg-[#123824] text-xs font-bold text-white">
<span>{{ messengerConnectionInitials(telegramConnected, 'TG') }}</span>
</div>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#123824]">
{{ messengerConnectionName(telegramConnected) }}
</p>
<p class="truncate text-xs text-[#5c7b69]">
{{ messengerConnectionHandle(telegramConnected) || 'Telegram подключен' }}
</p>
</div>
</div>
<div v-if="maxConnected" class="flex items-center gap-3 rounded-2xl bg-[#f8fbf9] px-3 py-2">
<div class="avatar placeholder">
<div class="h-10 w-10 rounded-full bg-[#2b7fff] text-xs font-bold text-white">
<span>{{ messengerConnectionInitials(maxConnected, 'MX') }}</span>
</div>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#123824]">
{{ messengerConnectionName(maxConnected) }}
</p>
<p class="truncate text-xs text-[#5c7b69]">
{{ messengerConnectionHandle(maxConnected) || 'Max подключен' }}
</p>
</div>
</div>
</div>
<p class="text-sm text-[#355947]">{{ notificationsSummary }}</p>
</NuxtLink>

View File

@@ -4,12 +4,22 @@ import {
MeDocument,
MyMessengerConnectionsDocument,
} from '~/composables/graphql/generated';
import {
messengerConnectionAvatarSrc,
messengerConnectionHandle,
messengerConnectionInitials,
messengerConnectionName,
} from '~/composables/useMessengerConnectionPresentation';
import { useMessengerStart } from '~/composables/useMessengerStart';
type MessengerItem = {
id: string;
type: 'TELEGRAM' | 'MAX';
isActive: boolean;
channelId: string;
displayName?: string | null;
username?: string | null;
avatarAvailable?: boolean | null;
};
const config = useRuntimeConfig();
@@ -63,6 +73,18 @@ const successText = computed(() =>
? 'Теперь этот Telegram привязан к вашему кабинету, и уведомления будут приходить в него.'
: 'Теперь этот Max привязан к вашему кабинету, и уведомления будут приходить в него.',
);
const successConnection = computed(() =>
successChannel.value === 'telegram' ? telegramConnection.value : maxConnection.value,
);
const successAvatarSrc = computed(() => messengerConnectionAvatarSrc(successConnection.value));
const successAvatarInitials = computed(() =>
messengerConnectionInitials(
successConnection.value,
profileInitials.value,
),
);
const successConnectionName = computed(() => messengerConnectionName(successConnection.value));
const successConnectionHandleValue = computed(() => messengerConnectionHandle(successConnection.value));
onMounted(() => {
if (showSuccess.value) {
@@ -92,18 +114,24 @@ async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
<div v-if="showSuccess" class="surface-card rounded-3xl border border-[#c7efd7] bg-[#f4fff8] p-5">
<div class="flex flex-col gap-4 md:flex-row md:items-center">
<div class="avatar placeholder">
<div v-if="successAvatarSrc" class="avatar">
<div class="h-18 w-18 rounded-full ring ring-[#c7efd7] ring-offset-2 ring-offset-white">
<img :src="successAvatarSrc" :alt="successConnectionName">
</div>
</div>
<div v-else class="avatar placeholder">
<div class="h-18 w-18 rounded-full bg-[#123824] text-xl font-bold text-white">
<span>{{ profileInitials }}</span>
<span>{{ successAvatarInitials }}</span>
</div>
</div>
<div class="space-y-1">
<div class="badge badge-success badge-outline">Успешно</div>
<h2 class="text-2xl font-extrabold text-[#0f2f20]">{{ successTitle }}</h2>
<p class="text-sm text-[#355947]">
{{ profileName }}
</p>
<div class="flex flex-wrap items-center gap-2 text-sm text-[#355947]">
<span class="font-semibold text-[#123824]">{{ successConnectionName }}</span>
<span v-if="successConnectionHandleValue">{{ successConnectionHandleValue }}</span>
</div>
<p class="text-sm text-[#355947]">
{{ successText }}
</p>
@@ -119,9 +147,27 @@ async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
<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>
<p class="text-sm opacity-80">
{{ telegramConnection ? `Подключен: ${telegramConnection.channelId}` : 'Не подключен' }}
</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>
<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>
</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 }"
@@ -140,9 +186,22 @@ async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
<p class="font-semibold">Max</p>
<p class="text-sm opacity-80">
{{ maxConnection ? `Подключен: ${maxConnection.channelId}` : 'Не подключен' }}
</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 }"

View File

@@ -2,5 +2,6 @@ query Me {
me {
id
email
fullName
}
}

View File

@@ -3,6 +3,9 @@ query MyMessengerConnections {
id
type
channelId
displayName
username
avatarAvailable
isActive
}
}

View File

@@ -141,6 +141,9 @@ type MessengerConnection {
userId: ID!
type: MessengerType!
channelId: String!
displayName: String
username: String
avatarAvailable: Boolean!
isActive: Boolean!
}

View File

@@ -0,0 +1,32 @@
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
const backendUrl = new URL(config.backendGraphqlUrl);
const connectionId = getRouterParam(event, 'connectionId');
if (!connectionId) {
throw createError({
statusCode: 400,
statusMessage: 'Connection id is required.',
});
}
const response = await fetch(`${backendUrl.origin}/messenger/avatar/${encodeURIComponent(connectionId)}`, {
headers: {
...(getHeader(event, 'cookie') ? { cookie: getHeader(event, 'cookie') as string } : {}),
...(getHeader(event, 'authorization') ? { authorization: getHeader(event, 'authorization') as string } : {}),
},
});
if (!response.ok || !response.body) {
throw createError({
statusCode: response.status,
statusMessage: 'Messenger avatar is not available.',
});
}
setResponseStatus(event, response.status);
setHeader(event, 'content-type', response.headers.get('content-type') || 'image/jpeg');
setHeader(event, 'cache-control', response.headers.get('cache-control') || 'private, max-age=300');
return Buffer.from(await response.arrayBuffer());
});