feat: добавлена функциональность политики конфиденциальности и согласия на обработку ПД
Основные изменения: Backend: - Добавлена миграция для поля privacy_consented_at в таблицу telecart_customers - Создан PrivacyPolicyHandler с методами: * checkIsUserPrivacyConsented - проверка наличия согласия пользователя * userPrivacyConsent - сохранение согласия пользователя - Обновлен TelegramService для извлечения userId из initData - Обновлен TelegramServiceProvider для внедрения зависимостей - Добавлены новые маршруты в routes.php - Обновлен SettingsHandler для возврата privacy_policy_link - Обновлен TelegramCustomersHandler для включения privacy_consented_at в ответы - Обновлены тесты TelegramServiceTest Frontend (SPA): - Создан компонент PrivacyPolicy.vue для отображения запроса согласия - Добавлена проверка согласия при инициализации приложения (main.js) - Обновлен App.vue для отображения компонента PrivacyPolicy - Добавлены функции checkIsUserPrivacyConsented и userPrivacyConsent в ftch.js - Обновлен SettingsStore для хранения privacy_policy_link и is_privacy_consented Frontend (Admin): - Добавлено поле privacy_policy_link в настройки (settings.js) - Добавлена настройка ссылки на политику конфиденциальности в GeneralView.vue - Обновлен CustomersView.vue: * Добавлена колонка privacy_consented_at с отображением даты согласия * Добавлена поддержка help-текста для колонок с иконкой вопроса и tooltip * Добавлены help-тексты для колонок last_seen_at, privacy_consented_at, created_at * Улучшено форматирование кода
This commit is contained in:
@@ -17,6 +17,7 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
theme_light: 'light',
|
theme_light: 'light',
|
||||||
theme_dark: 'dark',
|
theme_dark: 'dark',
|
||||||
app_debug: false,
|
app_debug: false,
|
||||||
|
privacy_policy_link: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
telegram: {
|
telegram: {
|
||||||
|
|||||||
@@ -67,13 +67,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</OverlayPanel>
|
</OverlayPanel>
|
||||||
<Button icon="fa fa-refresh" @click="loadCustomers" v-tooltip.top="'Обновить таблицу'" size="small"/>
|
<Button icon="fa fa-refresh" @click="loadCustomers" v-tooltip.top="'Обновить таблицу'"
|
||||||
<Button icon="fa fa-times-circle" label="Сбросить фильтры" @click="resetFilters" v-tooltip.top="'Сбросить все фильтры'" size="small"/>
|
size="small"/>
|
||||||
|
<Button icon="fa fa-times-circle" label="Сбросить фильтры" @click="resetFilters"
|
||||||
|
v-tooltip.top="'Сбросить все фильтры'" size="small"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconField>
|
<IconField>
|
||||||
<InputIcon class="fa fa-search" />
|
<InputIcon class="fa fa-search"/>
|
||||||
<InputText v-model="globalSearchValue" placeholder="Поиск по таблице..." @input="onGlobalSearch" />
|
<InputText v-model="globalSearchValue" placeholder="Поиск по таблице..."
|
||||||
|
@input="onGlobalSearch"/>
|
||||||
</IconField>
|
</IconField>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -91,10 +94,25 @@
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column v-for="col in selectedColumns" :key="col.field" :field="col.field" :header="col.header" :sortable="col.sortable" :dataType="col.dataType" :showFilterMenu="col.filterable !== false">
|
<Column v-for="col in selectedColumns" :key="col.field" :field="col.field"
|
||||||
|
:sortable="col.sortable" :dataType="col.dataType"
|
||||||
|
:showFilterMenu="col.filterable !== false">
|
||||||
|
<template #header>
|
||||||
|
<div class="tw:flex tw:items-center tw:gap-1">
|
||||||
|
<span>{{ col.header }}</span>
|
||||||
|
<i
|
||||||
|
v-if="col.help"
|
||||||
|
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help"
|
||||||
|
v-tooltip.top="col.help"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<template v-if="col.field === 'id'">{{ data.id }}</template>
|
<template v-if="col.field === 'id'">{{ data.id }}</template>
|
||||||
<template v-else-if="col.field === 'telegram_user_id'">{{ data.telegram_user_id }}</template>
|
<template v-else-if="col.field === 'telegram_user_id'">{{
|
||||||
|
data.telegram_user_id
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
<template v-else-if="col.field === 'username'">
|
<template v-else-if="col.field === 'username'">
|
||||||
<div class="tw:flex tw:items-center tw:gap-2">
|
<div class="tw:flex tw:items-center tw:gap-2">
|
||||||
<div v-if="data.photo_url" class="tw:relative">
|
<div v-if="data.photo_url" class="tw:relative">
|
||||||
@@ -132,25 +150,35 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="col.field === 'privacy_consented_at'">
|
||||||
|
<span v-if="data.privacy_consented_at">
|
||||||
|
<i class="fa fa-clock-o"></i> {{ formatDate(data.privacy_consented_at) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
<template v-else-if="col.field === 'created_at'">
|
<template v-else-if="col.field === 'created_at'">
|
||||||
<i class="fa fa-calendar"></i> {{ formatDate(data.created_at) }}
|
<i class="fa fa-calendar"></i> {{ formatDate(data.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #filter="{ filterModel }">
|
<template #filter="{ filterModel }">
|
||||||
<template v-if="col.field === 'telegram_user_id'">
|
<template v-if="col.field === 'telegram_user_id'">
|
||||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по ID" class="p-column-filter" />
|
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по ID"
|
||||||
|
class="p-column-filter"/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="col.field === 'username'">
|
<template v-else-if="col.field === 'username'">
|
||||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по имени пользователя" class="p-column-filter" />
|
<InputText v-model="filterModel.value" type="text"
|
||||||
|
placeholder="Поиск по имени пользователя" class="p-column-filter"/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="col.field === 'first_name'">
|
<template v-else-if="col.field === 'first_name'">
|
||||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по имени" class="p-column-filter" />
|
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по имени"
|
||||||
|
class="p-column-filter"/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="col.field === 'last_name'">
|
<template v-else-if="col.field === 'last_name'">
|
||||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии" class="p-column-filter" />
|
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии"
|
||||||
|
class="p-column-filter"/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="['last_seen_at', 'created_at'].includes(col.field)">
|
<template v-else-if="['last_seen_at', 'created_at'].includes(col.field)">
|
||||||
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy" />
|
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy"/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="col.field === 'is_premium'">
|
<template v-else-if="col.field === 'is_premium'">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -204,7 +232,8 @@
|
|||||||
<div style="font-size: 0.9rem; color: #666;">
|
<div style="font-size: 0.9rem; color: #666;">
|
||||||
ID: {{ selectedCustomer.telegram_user_id }}
|
ID: {{ selectedCustomer.telegram_user_id }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!selectedCustomer.allows_write_to_pm" style="color: #f59e0b; margin-top: 0.5rem;">
|
<div v-if="!selectedCustomer.allows_write_to_pm"
|
||||||
|
style="color: #f59e0b; margin-top: 0.5rem;">
|
||||||
<i class="fa fa-exclamation-triangle"></i> Пользователь не разрешил писать ему в PM
|
<i class="fa fa-exclamation-triangle"></i> Пользователь не разрешил писать ему в PM
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,8 +274,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import {onMounted, ref} from 'vue';
|
||||||
import { FilterMatchMode, FilterOperator } from '@primevue/core/api';
|
import {FilterMatchMode, FilterOperator} from '@primevue/core/api';
|
||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import DatePicker from 'primevue/datepicker';
|
import DatePicker from 'primevue/datepicker';
|
||||||
@@ -257,8 +286,8 @@ import Textarea from 'primevue/textarea';
|
|||||||
import OverlayPanel from 'primevue/overlaypanel';
|
import OverlayPanel from 'primevue/overlaypanel';
|
||||||
import Checkbox from 'primevue/checkbox';
|
import Checkbox from 'primevue/checkbox';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import { apiPost } from '@/utils/http.js';
|
import {apiPost} from '@/utils/http.js';
|
||||||
import { useToast, IconField, InputIcon } from 'primevue';
|
import {IconField, InputIcon, useToast} from 'primevue';
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const customers = ref([]);
|
const customers = ref([]);
|
||||||
@@ -270,16 +299,59 @@ const messageText = ref('');
|
|||||||
const sendingMessage = ref(false);
|
const sendingMessage = ref(false);
|
||||||
|
|
||||||
const columns = ref([
|
const columns = ref([
|
||||||
{ field: 'id', header: '№', sortable: true, filterable: false, visible: true },
|
{field: 'id', header: '№', sortable: true, filterable: false, visible: true},
|
||||||
{ field: 'telegram_user_id', header: 'ID в Telegram', sortable: true, filterable: true, visible: false },
|
{
|
||||||
{ field: 'username', header: 'Имя пользователя', sortable: true, filterable: true, visible: true },
|
field: 'telegram_user_id',
|
||||||
{ field: 'first_name', header: 'Имя', sortable: true, filterable: true, visible: true },
|
header: 'ID в Telegram',
|
||||||
{ field: 'last_name', header: 'Фамилия', sortable: true, filterable: true, visible: true },
|
sortable: true,
|
||||||
{ field: 'language_code', header: 'Язык интерфейса', sortable: true, filterable: false, visible: false },
|
filterable: true,
|
||||||
{ field: 'is_premium', header: 'Премиум статус', sortable: true, filterable: true, visible: true },
|
visible: false
|
||||||
{ field: 'oc_customer_id', header: 'ID покупателя', sortable: true, filterable: false, visible: false },
|
},
|
||||||
{ field: 'last_seen_at', header: 'Последний визит', sortable: true, dataType: 'date', filterable: true, visible: true },
|
{field: 'username', header: 'Имя пользователя', sortable: true, filterable: true, visible: true},
|
||||||
{ field: 'created_at', header: 'Дата регистрации', sortable: true, dataType: 'date', filterable: true, visible: true },
|
{field: 'first_name', header: 'Имя', sortable: true, filterable: true, visible: true},
|
||||||
|
{field: 'last_name', header: 'Фамилия', sortable: true, filterable: true, visible: true},
|
||||||
|
{
|
||||||
|
field: 'language_code',
|
||||||
|
header: 'Язык интерфейса',
|
||||||
|
sortable: true,
|
||||||
|
filterable: false,
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
{field: 'is_premium', header: 'Премиум статус', sortable: true, filterable: true, visible: true},
|
||||||
|
{
|
||||||
|
field: 'oc_customer_id',
|
||||||
|
header: 'ID покупателя',
|
||||||
|
sortable: true,
|
||||||
|
filterable: false,
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'last_seen_at',
|
||||||
|
header: 'Последний визит',
|
||||||
|
sortable: true,
|
||||||
|
dataType: 'date',
|
||||||
|
filterable: true,
|
||||||
|
visible: true,
|
||||||
|
help: 'Показывает, когда пользователь последний раз открывал Telegram магазин.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'privacy_consented_at',
|
||||||
|
header: 'Согласие ПД',
|
||||||
|
sortable: true,
|
||||||
|
dataType: 'date',
|
||||||
|
filterable: true,
|
||||||
|
visible: true,
|
||||||
|
help: 'Показывает, когда пользователь дал согласие на обработку персональных данных.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'created_at',
|
||||||
|
header: 'Дата регистрации',
|
||||||
|
sortable: true,
|
||||||
|
dataType: 'date',
|
||||||
|
filterable: true,
|
||||||
|
visible: true,
|
||||||
|
help: 'Показывает, когда пользователь впервые открыл Telegram магазин.',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const selectedColumns = ref(columns.value.filter(col => col.visible !== false));
|
const selectedColumns = ref(columns.value.filter(col => col.visible !== false));
|
||||||
@@ -314,9 +386,9 @@ function deselectAllColumns() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const premiumFilterOptions = [
|
const premiumFilterOptions = [
|
||||||
{ label: 'Любой', value: null },
|
{label: 'Любой', value: null},
|
||||||
{ label: 'Нет', value: false },
|
{label: 'Нет', value: false},
|
||||||
{ label: 'Да', value: true },
|
{label: 'Да', value: true},
|
||||||
];
|
];
|
||||||
|
|
||||||
const lazyParams = ref({
|
const lazyParams = ref({
|
||||||
@@ -329,14 +401,35 @@ const lazyParams = ref({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
global: {value: null, matchMode: FilterMatchMode.CONTAINS},
|
||||||
telegram_user_id: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }] },
|
telegram_user_id: {
|
||||||
username: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
operator: FilterOperator.AND,
|
||||||
first_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
constraints: [{value: null, matchMode: FilterMatchMode.STARTS_WITH}]
|
||||||
last_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
},
|
||||||
is_premium: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.EQUALS }] },
|
username: {
|
||||||
created_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
operator: FilterOperator.AND,
|
||||||
last_seen_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||||
|
},
|
||||||
|
first_name: {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||||
|
},
|
||||||
|
last_name: {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||||
|
},
|
||||||
|
is_premium: {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||||
|
},
|
||||||
|
last_seen_at: {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function processFiltersForBackend(filtersObj) {
|
function processFiltersForBackend(filtersObj) {
|
||||||
@@ -447,14 +540,35 @@ function onGlobalSearch() {
|
|||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
globalSearchValue.value = '';
|
globalSearchValue.value = '';
|
||||||
filters.value = {
|
filters.value = {
|
||||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
global: {value: null, matchMode: FilterMatchMode.CONTAINS},
|
||||||
telegram_user_id: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }] },
|
telegram_user_id: {
|
||||||
username: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
operator: FilterOperator.AND,
|
||||||
first_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
constraints: [{value: null, matchMode: FilterMatchMode.STARTS_WITH}]
|
||||||
last_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
},
|
||||||
is_premium: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.EQUALS }] },
|
username: {
|
||||||
created_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
operator: FilterOperator.AND,
|
||||||
last_seen_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||||
|
},
|
||||||
|
first_name: {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||||
|
},
|
||||||
|
last_name: {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||||
|
},
|
||||||
|
is_premium: {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||||
|
},
|
||||||
|
last_seen_at: {
|
||||||
|
operator: FilterOperator.AND,
|
||||||
|
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||||
|
},
|
||||||
};
|
};
|
||||||
lazyParams.value.page = 1;
|
lazyParams.value.page = 1;
|
||||||
lazyParams.value.first = 0;
|
lazyParams.value.first = 0;
|
||||||
|
|||||||
@@ -18,6 +18,17 @@
|
|||||||
Изображение, которое будет отображаться в Telegram Mini App.
|
Изображение, которое будет отображаться в Telegram Mini App.
|
||||||
</ItemImage>
|
</ItemImage>
|
||||||
|
|
||||||
|
<ItemInput label="Политика конфиденциальности"
|
||||||
|
v-model="settings.items.app.privacy_policy_link"
|
||||||
|
placeholder="https://your-site/privacy-polisy"
|
||||||
|
>
|
||||||
|
Укажите ссылку на страницу с вашей Политикой конфиденциальности.
|
||||||
|
Она будет показываться пользователям для получения согласия на обработку персональных данных.
|
||||||
|
Вы сможете видеть, кто согласился, на странице с
|
||||||
|
<RouterLink to="/customers">Telegram покупателями</RouterLink>
|
||||||
|
.
|
||||||
|
</ItemInput>
|
||||||
|
|
||||||
<ItemSelect label="Светлая тема" v-model="settings.items.app.theme_light" :items="themes">
|
<ItemSelect label="Светлая тема" v-model="settings.items.app.theme_light" :items="themes">
|
||||||
Выберите стиль, который будет использоваться при отображении вашего магазина
|
Выберите стиль, который будет использоваться при отображении вашего магазина
|
||||||
в Telegram для дневного режима.
|
в Telegram для дневного режима.
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
|
|
||||||
|
<PrivacyPolicy v-if="! settings.is_privacy_consented"/>
|
||||||
|
|
||||||
<CartButton v-if="settings.store_enabled"/>
|
<CartButton v-if="settings.store_enabled"/>
|
||||||
<Dock v-if="isAppDockShown"/>
|
<Dock v-if="isAppDockShown"/>
|
||||||
</section>
|
</section>
|
||||||
@@ -42,8 +44,8 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
|
|||||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||||
import CartButton from "@/components/CartButton.vue";
|
import CartButton from "@/components/CartButton.vue";
|
||||||
import Dock from "@/components/Dock.vue";
|
import Dock from "@/components/Dock.vue";
|
||||||
import Navbar from "@/components/Navbar.vue";
|
|
||||||
import AppDebugMessage from "@/components/AppDebugMessage.vue";
|
import AppDebugMessage from "@/components/AppDebugMessage.vue";
|
||||||
|
import PrivacyPolicy from "@/components/PrivacyPolicy.vue";
|
||||||
|
|
||||||
const tg = useMiniApp();
|
const tg = useMiniApp();
|
||||||
const platform = ref();
|
const platform = ref();
|
||||||
|
|||||||
42
frontend/spa/src/components/PrivacyPolicy.vue
Normal file
42
frontend/spa/src/components/PrivacyPolicy.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="isShown" class="toast toast-center bottom-20 z-50">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<span>
|
||||||
|
Используя магазин, вы соглашаетесь с
|
||||||
|
<a v-if="settings.privacy_policy_link"
|
||||||
|
href="#" class="underline"
|
||||||
|
@click.prevent="showPrivacyPolicy"
|
||||||
|
>обработкой персональных данных</a>
|
||||||
|
<span v-else>обработкой персональных данных</span>.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline"
|
||||||
|
@click="privacyConsent"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {userPrivacyConsent} from "@/utils/ftch.js";
|
||||||
|
import {ref} from "vue";
|
||||||
|
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||||
|
|
||||||
|
const isShown = ref(true);
|
||||||
|
const settings = useSettingsStore();
|
||||||
|
|
||||||
|
async function privacyConsent() {
|
||||||
|
isShown.value = false;
|
||||||
|
await userPrivacyConsent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPrivacyPolicy() {
|
||||||
|
if (settings.privacy_policy_link) {
|
||||||
|
window.Telegram.WebApp.openLink(settings.privacy_policy_link, {
|
||||||
|
try_instant_view: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -8,7 +8,7 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
|
|||||||
import ApplicationError from "@/ApplicationError.vue";
|
import ApplicationError from "@/ApplicationError.vue";
|
||||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||||
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
||||||
import {saveTelegramCustomer} from "@/utils/ftch.js";
|
import {checkIsUserPrivacyConsented, saveTelegramCustomer} from "@/utils/ftch.js";
|
||||||
|
|
||||||
import {register} from 'swiper/element/bundle';
|
import {register} from 'swiper/element/bundle';
|
||||||
import 'swiper/element/bundle';
|
import 'swiper/element/bundle';
|
||||||
@@ -59,6 +59,22 @@ settings.load()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.then(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await checkIsUserPrivacyConsented();
|
||||||
|
settings.is_privacy_consented = response?.data?.is_privacy_consented;
|
||||||
|
if (settings.is_privacy_consented) {
|
||||||
|
console.info('[Init] Privacy Policy consent given by user.');
|
||||||
|
} else {
|
||||||
|
console.info('[Init] Privacy Policy consent NOT given by user.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Init] Failed to check Telegram user consent.');
|
||||||
|
settings.is_privacy_consented = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
})
|
||||||
.then(() => blocks.processBlocks(settings.mainpage_blocks))
|
.then(() => blocks.processBlocks(settings.mainpage_blocks))
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
console.debug('Load default filters for the main page');
|
console.debug('Load default filters for the main page');
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
text_order_created_success: 'Заказ успешно оформлен.',
|
text_order_created_success: 'Заказ успешно оформлен.',
|
||||||
},
|
},
|
||||||
mainpage_blocks: [],
|
mainpage_blocks: [],
|
||||||
|
is_privacy_consented: false,
|
||||||
|
privacy_policy_link: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
@@ -53,6 +55,7 @@ export const useSettingsStore = defineStore('settings', {
|
|||||||
this.currency_code = settings.currency_code;
|
this.currency_code = settings.currency_code;
|
||||||
this.texts = settings.texts;
|
this.texts = settings.texts;
|
||||||
this.mainpage_blocks = settings.mainpage_blocks;
|
this.mainpage_blocks = settings.mainpage_blocks;
|
||||||
|
this.privacy_policy_link = settings.privacy_policy_link;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,4 +107,12 @@ export async function saveTelegramCustomer(userData) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkIsUserPrivacyConsented() {
|
||||||
|
return await ftch('checkIsUserPrivacyConsented');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userPrivacyConsent() {
|
||||||
|
return await ftch('userPrivacyConsent');
|
||||||
|
}
|
||||||
|
|
||||||
export default ftch;
|
export default ftch;
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class TelegramCustomersHandler
|
|||||||
'photo_url',
|
'photo_url',
|
||||||
'last_seen_at',
|
'last_seen_at',
|
||||||
'referral',
|
'referral',
|
||||||
|
'privacy_consented_at',
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
])
|
])
|
||||||
@@ -322,6 +323,7 @@ class TelegramCustomersHandler
|
|||||||
'photo_url' => $customer['photo_url'],
|
'photo_url' => $customer['photo_url'],
|
||||||
'last_seen_at' => $customer['last_seen_at'],
|
'last_seen_at' => $customer['last_seen_at'],
|
||||||
'referral' => $customer['referral'],
|
'referral' => $customer['referral'],
|
||||||
|
'privacy_consented_at' => $customer['privacy_consented_at'],
|
||||||
'created_at' => $customer['created_at'],
|
'created_at' => $customer['created_at'],
|
||||||
'updated_at' => $customer['updated_at'],
|
'updated_at' => $customer['updated_at'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$sql = <<<SQL
|
||||||
|
ALTER TABLE `telecart_customers`
|
||||||
|
ADD COLUMN `privacy_consented_at` TIMESTAMP NULL DEFAULT NULL AFTER `referral`;
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$this->database->statement($sql);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -7,15 +7,24 @@ use GuzzleHttp\Exception\ClientException;
|
|||||||
use GuzzleHttp\Exception\GuzzleException;
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
use Openguru\OpenCartFramework\Support\Arr;
|
use Openguru\OpenCartFramework\Support\Arr;
|
||||||
use Openguru\OpenCartFramework\Telegram\Enums\ChatAction;
|
use Openguru\OpenCartFramework\Telegram\Enums\ChatAction;
|
||||||
|
use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
|
||||||
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramClientException;
|
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramClientException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class TelegramService
|
class TelegramService
|
||||||
{
|
{
|
||||||
private ?string $botToken;
|
private ?string $botToken;
|
||||||
|
private TelegramInitDataDecoder $initDataDecoder;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
public function __construct(?string $botToken = null)
|
public function __construct(
|
||||||
{
|
TelegramInitDataDecoder $initDataDecoder,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
?string $botToken = null
|
||||||
|
) {
|
||||||
$this->botToken = $botToken;
|
$this->botToken = $botToken;
|
||||||
|
$this->initDataDecoder = $initDataDecoder;
|
||||||
|
$this->logger = $logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function escapeTelegramMarkdownV2(string $text): string
|
public function escapeTelegramMarkdownV2(string $text): string
|
||||||
@@ -194,4 +203,21 @@ class TelegramService
|
|||||||
|
|
||||||
return $response['result'];
|
return $response['result'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function userId(?string $initDataRaw = null): ?int
|
||||||
|
{
|
||||||
|
if (! $initDataRaw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$decoded = $this->initDataDecoder->decode($initDataRaw);
|
||||||
|
|
||||||
|
return Arr::get($decoded, 'user.id');
|
||||||
|
} catch (DecodeTelegramInitDataException $e) {
|
||||||
|
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ class TelegramServiceProvider extends ServiceProvider
|
|||||||
$this->container->singleton(TelegramService::class, function (Application $app) {
|
$this->container->singleton(TelegramService::class, function (Application $app) {
|
||||||
$botToken = $app->getConfigValue('telegram.bot_token');
|
$botToken = $app->getConfigValue('telegram.bot_token');
|
||||||
|
|
||||||
return new TelegramService($botToken);
|
return new TelegramService(
|
||||||
|
$app->get(TelegramInitDataDecoder::class),
|
||||||
|
$app->get(LoggerInterface::class),
|
||||||
|
$botToken,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->container->singleton(SignatureValidator::class, function (Application $app) {
|
$this->container->singleton(SignatureValidator::class, function (Application $app) {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use App\Models\TelegramCustomer;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||||
|
use Openguru\OpenCartFramework\Http\Request;
|
||||||
|
use Openguru\OpenCartFramework\Http\Response;
|
||||||
|
use Openguru\OpenCartFramework\Support\Arr;
|
||||||
|
use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader;
|
||||||
|
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
class PrivacyPolicyHandler
|
||||||
|
{
|
||||||
|
private TelegramService $telegramService;
|
||||||
|
private TelegramCustomer $telegramCustomer;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
TelegramService $telegramService,
|
||||||
|
TelegramCustomer $telegramCustomer,
|
||||||
|
LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
$this->telegramService = $telegramService;
|
||||||
|
$this->telegramCustomer = $telegramCustomer;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkIsUserPrivacyConsented(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$isPrivacyConsented = false;
|
||||||
|
$telegramUserId = $this->telegramService->userId($request->header(TelegramHeader::INIT_DATA));
|
||||||
|
|
||||||
|
if (! $telegramUserId) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'is_privacy_consented' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
|
||||||
|
|
||||||
|
if ($customer) {
|
||||||
|
$isPrivacyConsented = Arr::get($customer, 'privacy_consented_at') !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'is_privacy_consented' => $isPrivacyConsented,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userPrivacyConsent(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$telegramUserId = $this->telegramService->userId($request->header(TelegramHeader::INIT_DATA));
|
||||||
|
|
||||||
|
if ($telegramUserId) {
|
||||||
|
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, [
|
||||||
|
'privacy_consented_at' => Carbon::now()->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$this->logger->warning(
|
||||||
|
'Could not find customer with telegram user_id: ' . $telegramUserId . ' to give privacy consent.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ class SettingsHandler
|
|||||||
'currency_code' => $this->settings->config()->getStore()->getOcDefaultCurrency(),
|
'currency_code' => $this->settings->config()->getStore()->getOcDefaultCurrency(),
|
||||||
'texts' => $this->settings->config()->getTexts()->toArray(),
|
'texts' => $this->settings->config()->getTexts()->toArray(),
|
||||||
'mainpage_blocks' => $this->settings->get('mainpage_blocks', []),
|
'mainpage_blocks' => $this->settings->get('mainpage_blocks', []),
|
||||||
|
'privacy_policy_link' => $this->settings->get('app.privacy_policy_link'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ use App\Handlers\FiltersHandler;
|
|||||||
use App\Handlers\FormsHandler;
|
use App\Handlers\FormsHandler;
|
||||||
use App\Handlers\HealthCheckHandler;
|
use App\Handlers\HealthCheckHandler;
|
||||||
use App\Handlers\OrderHandler;
|
use App\Handlers\OrderHandler;
|
||||||
|
use App\Handlers\PrivacyPolicyHandler;
|
||||||
use App\Handlers\ProductsHandler;
|
use App\Handlers\ProductsHandler;
|
||||||
use App\Handlers\SettingsHandler;
|
use App\Handlers\SettingsHandler;
|
||||||
use App\Handlers\TelegramHandler;
|
|
||||||
use App\Handlers\TelegramCustomerHandler;
|
use App\Handlers\TelegramCustomerHandler;
|
||||||
|
use App\Handlers\TelegramHandler;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'categoriesList' => [CategoriesHandler::class, 'index'],
|
'categoriesList' => [CategoriesHandler::class, 'index'],
|
||||||
|
'checkIsUserPrivacyConsented' => [PrivacyPolicyHandler::class, 'checkIsUserPrivacyConsented'],
|
||||||
'checkout' => [CartHandler::class, 'checkout'],
|
'checkout' => [CartHandler::class, 'checkout'],
|
||||||
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
|
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
|
||||||
'getCart' => [CartHandler::class, 'index'],
|
'getCart' => [CartHandler::class, 'index'],
|
||||||
@@ -27,5 +29,6 @@ return [
|
|||||||
'settings' => [SettingsHandler::class, 'index'],
|
'settings' => [SettingsHandler::class, 'index'],
|
||||||
'storeOrder' => [OrderHandler::class, 'store'],
|
'storeOrder' => [OrderHandler::class, 'store'],
|
||||||
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
||||||
|
'userPrivacyConsent' => [PrivacyPolicyHandler::class, 'userPrivacyConsent'],
|
||||||
'webhook' => [TelegramHandler::class, 'webhook'],
|
'webhook' => [TelegramHandler::class, 'webhook'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Telegram;
|
namespace Telegram;
|
||||||
|
|
||||||
|
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
|
||||||
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -12,7 +13,10 @@ class TelegramServiceTest extends TestCase
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
$this->service = new TelegramService();
|
$this->service = new TelegramService(
|
||||||
|
new TelegramInitDataDecoder(),
|
||||||
|
$this->getNullLogger(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDoesNotEscapeNormalCharacters(): void
|
public function testDoesNotEscapeNormalCharacters(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user