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:
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -138,9 +138,9 @@ export async function fetchProductById(productId) {
|
||||
}
|
||||
|
||||
export async function fetchProductImages(productId) {
|
||||
return await ftch('getProductImages', {
|
||||
id: productId,
|
||||
});
|
||||
return await ftch('getProductImages', {
|
||||
id: productId,
|
||||
});
|
||||
}
|
||||
|
||||
export default ftch;
|
||||
|
||||
236
frontend/spa/src/views/Account.vue
Normal file
236
frontend/spa/src/views/Account.vue
Normal 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>
|
||||
|
||||
@@ -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 пользователя из запроса
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user