feat: add customer account page with profile information and actions

- Create Account.vue page component with user profile display
- Add account route to router.js
- Update Navbar.vue to remove avatar button (moved to Dock)
- Add avatar icon to Dock.vue for account page navigation
- Implement 'Contact us' action that opens manager chat via Telegram
- Implement 'Add to home screen' feature using Telegram Web App API 8.0+
- Add home screen status checking with checkHomeScreenStatus API
- Display customer registration date and days with us counter
- Add Russian language declension for days word (день/дня/дней)
- Update TelegramCustomerHandler to return created_at in saveOrUpdate response
- Add getByTelegramUserId method to TelecartCustomerService
- Store customer_created_at in Pulse store during app initialization
- Update App.vue to show Dock on account page
- Remove unused getCurrentCustomer API endpoint and function
This commit is contained in:
2025-12-25 21:32:25 +03:00
parent 0a7877ddbe
commit ad94afda68
9 changed files with 314 additions and 19 deletions

View File

@@ -60,6 +60,22 @@
</div>
<span class="dock-label">Корзина</span>
</RouterLink>
<RouterLink
:to="{name: 'account'}"
:class="{'dock-active': route.name === 'account'}"
@click="onDockItemClick"
>
<div v-if="tgData?.user?.photo_url" class="w-6 h-6 rounded-full overflow-hidden">
<img :src="tgData?.user?.photo_url" alt="avatar" class="w-full h-full object-cover"/>
</div>
<div v-else class="bg-primary text-primary-content w-6 h-6 rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</div>
<span class="dock-label">Профиль</span>
</RouterLink>
</div>
</template>
@@ -67,10 +83,12 @@
import {useRoute} from "vue-router";
import {useCartStore} from "@/stores/CartStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useTgData} from "@/composables/useTgData.js";
const route = useRoute();
const cart = useCartStore();
const settings = useSettingsStore();
const tgData = useTgData();
const haptic = window.Telegram.WebApp.HapticFeedback;
function onDockItemClick() {

View File

@@ -21,32 +21,16 @@
</div>
<div class="navbar-end">
<div v-if="tgData?.user?.photo_url" class="avatar">
<div class="w-8 h-8 rounded-full">
<img :src="tgData?.user?.photo_url" alt="avatar"/>
</div>
</div>
<div v-else class="avatar avatar-placeholder">
<div class="bg-primary text-primary-content w-8 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useTgData} from "@/composables/useTgData.js";
const settings = useSettingsStore();
const emits = defineEmits(['drawer']);
const tgData = useTgData();
function toggleDrawer() {
emits('drawer');
}

View File

@@ -8,6 +8,7 @@ import Checkout from "@/views/Checkout.vue";
import OrderCreated from "@/views/OrderCreated.vue";
import Search from "@/views/Search.vue";
import Filters from "@/views/Filters.vue";
import Account from "@/views/Account.vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
const routes = [
@@ -29,6 +30,7 @@ const routes = [
{path: '/checkout', name: 'checkout', component: Checkout},
{path: '/success', name: 'order_created', component: OrderCreated},
{path: '/search', name: 'search', component: Search},
{path: '/account', name: 'account', component: Account},
];
export const router = createRouter({

View File

@@ -7,6 +7,7 @@ export const usePulseStore = defineStore('pulse', {
state: () => ({
tracking_id: null,
campaign_id: null,
customer_created_at: null,
}),
actions: {
@@ -40,6 +41,7 @@ export const usePulseStore = defineStore('pulse', {
saveTelegramCustomer(userData)
.then((response) => {
this.tracking_id = response?.data?.tracking_id || this.tracking_id || null;
this.customer_created_at = response?.data?.created_at || null;
console.debug(
'[Pulse] Telegram customer data saved successfully. Tracking ID: ',
toRaw(this.tracking_id)

View File

@@ -0,0 +1,236 @@
<template>
<BaseViewWrapper>
<div class="account-page">
<!-- Профиль пользователя -->
<div class="card card-border bg-base-100 mb-4">
<div class="card-body">
<div class="flex items-center gap-4">
<div class="avatar">
<div v-if="tgData?.user?.photo_url" class="w-16 h-16 rounded-full">
<img :src="tgData?.user?.photo_url" alt="avatar"/>
</div>
<div v-else class="bg-primary text-primary-content w-16 h-16 rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</div>
</div>
<div class="flex-1">
<h2 class="text-xl font-bold">
{{ username }}
</h2>
<p v-if="tgData?.user?.username" class="text-sm text-base-content/70">
@{{ tgData.user.username }}
</p>
<p v-if="daysWithUs !== null" class="text-sm text-base-content/40">
Вы с нами {{ daysWithUs }} {{ daysWord }}
</p>
</div>
</div>
</div>
</div>
<!-- Список пунктов меню -->
<div class="menu bg-base-100 rounded-box card card-border w-full mb-4">
<li class="w-full">
<a @click="openManagerChat" class="flex items-center gap-3 w-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 8.07 8.07 0 0 0-1.603-.093C9.5 7.5 8.25 8.25 8.25 9.75v4.286c0 .597.237 1.17.659 1.591l3.682 3.682a2.25 2.25 0 0 0 1.591.659h4.286c.597 0 1.17-.237 1.591-.659l3.682-3.682a2.25 2.25 0 0 0 .659-1.591V10.608c0-1.136-.847-2.1-1.98-2.193a48.138 48.138 0 0 0-1.02-.072Z" />
</svg>
<span>Связаться с нами</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 ml-auto">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</a>
</li>
</div>
<!-- Кнопка добавления на главный экран -->
<div v-if="isHomeScreenSupported" class="card card-border bg-base-100">
<div class="card-body p-4">
<button
v-if="!isHomeScreenAdded"
@click="addToHomeScreen"
class="btn btn-outline w-full justify-center gap-3 mb-2"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
<span>Добавить на главный экран</span>
</button>
<div v-else class="flex items-center justify-center gap-3 mb-2 p-3 bg-success/10 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 text-success">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="text-success font-medium">Приложение уже добавлено на главный экран</span>
</div>
<p v-if="!isHomeScreenAdded" class="text-xs text-base-content/50 text-center">
Добавьте приложение на главный экран для быстрого доступа
</p>
</div>
</div>
</div>
</BaseViewWrapper>
</template>
<script setup>
import {computed, ref} from "vue";
import {useTgData} from "@/composables/useTgData.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import BaseViewWrapper from "@/views/BaseViewWrapper.vue";
import {onMounted, onUnmounted} from "vue";
import {useRoute} from "vue-router";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {usePulseStore} from "@/stores/Pulse.js";
defineOptions({
name: 'Account'
});
const tgData = useTgData();
const settings = useSettingsStore();
const route = useRoute();
const yaMetrika = useYaMetrikaStore();
const pulse = usePulseStore();
const username = computed(() => {
if (tgData?.user?.first_name || tgData?.user?.last_name) {
const parts = [tgData.user.first_name, tgData.user.last_name].filter(Boolean);
return parts.join(' ') || 'Пользователь';
}
return tgData?.user?.username ? `@${tgData.user.username}` : 'Пользователь';
});
const isHomeScreenSupported = ref(false);
const isHomeScreenAdded = ref(false);
const daysWithUs = ref(null);
// Функция для склонения слова "день" по правилам русского языка
function getDaysWord(days) {
const lastDigit = days % 10;
const lastTwoDigits = days % 100;
// Исключения для 11-14
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
return 'дней';
}
// Склонение по последней цифре
if (lastDigit === 1) {
return 'день';
} else if (lastDigit >= 2 && lastDigit <= 4) {
return 'дня';
} else {
return 'дней';
}
}
const daysWord = computed(() => {
if (daysWithUs.value === null) return '';
return getDaysWord(daysWithUs.value);
});
function openManagerChat() {
if (!settings.manager_username) {
window.Telegram.WebApp.showAlert('Менеджер недоступен');
return;
}
window.Telegram.WebApp.HapticFeedback.selectionChanged();
// Формируем ссылку для открытия чата с менеджером
const managerUsername = String(settings.manager_username).trim();
const username = managerUsername.startsWith('@')
? managerUsername.substring(1)
: managerUsername;
const chatUrl = `https://t.me/${username}`;
window.Telegram.WebApp.openTelegramLink(chatUrl);
}
function handleHomeScreenAdded() {
window.Telegram.WebApp.HapticFeedback.notificationOccurred('success');
window.Telegram.WebApp.showAlert('Приложение успешно добавлено на главный экран!');
isHomeScreenAdded.value = true;
}
function checkHomeScreenSupport() {
if (window.Telegram?.WebApp?.checkHomeScreenStatus) {
window.Telegram.WebApp.checkHomeScreenStatus((status) => {
isHomeScreenSupported.value = status !== 'unsupported';
if (status === 'added') {
isHomeScreenAdded.value = true;
}
});
} else if (window.Telegram?.WebApp?.addToHomeScreen) {
// Если есть метод addToHomeScreen, значит функция поддерживается
isHomeScreenSupported.value = true;
}
}
function addToHomeScreen() {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
// Используем Telegram Web App API для добавления на главный экран
if (window.Telegram?.WebApp?.addToHomeScreen) {
window.Telegram.WebApp.addToHomeScreen();
} else {
// Fallback для старых версий или если метод недоступен
window.Telegram.WebApp.showAlert('Функция добавления на главный экран недоступна в вашей версии Telegram.');
}
}
function calculateDaysWithUs() {
const createdAt = pulse.customer_created_at;
if (createdAt) {
const createdDate = new Date(createdAt);
const now = new Date();
const diffTime = now - createdDate;
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
// Если 0 дней, показываем 1 день
daysWithUs.value = diffDays === 0 ? 1 : diffDays;
}
}
onMounted(() => {
window.document.title = 'Профиль';
yaMetrika.pushHit(route.path, {
title: 'Профиль',
});
// Вычисляем количество дней с момента первого визита
calculateDaysWithUs();
// Проверяем поддержку функции добавления на главный экран
checkHomeScreenSupport();
// Регистрируем обработчик события успешного добавления на главный экран
if (window.Telegram?.WebApp?.onEvent) {
window.Telegram.WebApp.onEvent('homeScreenAdded', handleHomeScreenAdded);
}
});
onUnmounted(() => {
// Удаляем обработчик события при размонтировании компонента
if (window.Telegram?.WebApp?.offEvent) {
window.Telegram.WebApp.offEvent('homeScreenAdded', handleHomeScreenAdded);
}
});
</script>
<style scoped>
.account-page {
padding-bottom: 1rem;
}
.menu li > a {
padding: 1rem;
width: 100%;
}
.menu li > a:active {
background-color: hsl(var(--b2));
}
</style>

View File

@@ -49,6 +49,7 @@ class TelegramCustomerHandler
return new JsonResponse([
'data' => [
'tracking_id' => Arr::get($customer, 'tracking_id'),
'created_at' => Arr::get($customer, 'created_at'),
],
], Response::HTTP_OK);
} catch (Throwable $e) {
@@ -58,6 +59,46 @@ class TelegramCustomerHandler
}
}
/**
* Получить данные текущего пользователя
*
* @param Request $request HTTP запрос
* @return JsonResponse JSON ответ с данными пользователя
*/
public function getCurrent(Request $request): JsonResponse
{
try {
$telegramUserData = $this->extractUserDataFromInitData($request);
$telegramUserId = (int)Arr::get($telegramUserData, 'id');
if ($telegramUserId <= 0) {
return new JsonResponse([
'data' => null,
], Response::HTTP_OK);
}
$customer = $this->telegramCustomerService->getByTelegramUserId($telegramUserId);
if (!$customer) {
return new JsonResponse([
'data' => null,
], Response::HTTP_OK);
}
return new JsonResponse([
'data' => [
'created_at' => Arr::get($customer, 'created_at'),
],
], Response::HTTP_OK);
} catch (Throwable $e) {
$this->logger->error('Could not get current telegram customer data', ['exception' => $e]);
return new JsonResponse([
'data' => null,
], Response::HTTP_OK);
}
}
/**
* Извлечь данные Telegram пользователя из запроса
*

View File

@@ -111,4 +111,15 @@ class TelecartCustomerService
{
$this->telegramCustomer->increase($telecartCustomerId, 'orders_count');
}
/**
* Получить данные пользователя по Telegram user ID
*
* @param int $telegramUserId Telegram user ID
* @return array|null Данные пользователя или null если не найдено
*/
public function getByTelegramUserId(int $telegramUserId): ?array
{
return $this->telegramCustomer->findByTelegramUserId($telegramUserId);
}
}

View File

@@ -29,6 +29,7 @@ return [
'product_show' => [ProductsHandler::class, 'show'],
'products' => [ProductsHandler::class, 'index'],
'saveTelegramCustomer' => [TelegramCustomerHandler::class, 'saveOrUpdate'],
'getCurrentCustomer' => [TelegramCustomerHandler::class, 'getCurrent'],
'settings' => [SettingsHandler::class, 'index'],
'storeOrder' => [OrderHandler::class, 'store'],
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],