refactor: move spa to frontend folder

This commit is contained in:
2025-10-27 12:32:38 +03:00
parent 617b5491a1
commit 5681ac592a
77 changed files with 13 additions and 2 deletions

View File

@@ -0,0 +1,165 @@
import {defineStore} from "pinia";
import {isNotEmpty} from "@/helpers.js";
import {addToCart, cartEditItem, cartRemoveItem, getCart, setCoupon, setVoucher} from "@/utils/ftch.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
productsCount: 0,
total: 0,
isLoading: false,
reason: null,
error_warning: '',
attention: '',
success: '',
coupon: '',
voucher: '',
}),
getters: {
canCheckout: (state) => {
if (state.isLoading || state.error_warning.length > 0) {
return false;
}
},
},
actions: {
async getProducts() {
try {
this.isLoading = true;
const {data} = await getCart();
this.items = data.products;
this.productsCount = data.total_products_count;
this.totals = data.totals;
this.error_warning = data.error_warning;
this.attention = data.attention;
this.success = data.success;
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
},
async addProduct(productId, productName, price, quantity = 1, options = []) {
try {
this.isLoading = true;
const formData = new FormData();
formData.append("product_id", productId);
formData.append("quantity", quantity);
// TODO: Add support different types of options
options.forEach((option) => {
if (option.type === "checkbox" && Array.isArray(option.value)) {
option.value.forEach(item => {
formData.append(`option[${option.product_option_id}][]`, item.product_option_value_id);
});
} else if (option.type === "radio" && isNotEmpty(option.value)) {
formData.append(`option[${option.product_option_id}]`, option.value.product_option_value_id);
} else if (option.type === "select" && isNotEmpty(option.value)) {
formData.append(`option[${option.product_option_id}]`, option.value.product_option_value_id);
} else if ((option.type === "text" || option.type === 'textarea') && isNotEmpty(option.value)) {
formData.append(`option[${option.product_option_id}]`, option.value);
}
})
const response = await addToCart(formData);
if (response.error) {
throw new Error(JSON.stringify(response.error));
}
await this.getProducts();
} catch (error) {
console.log(error);
throw error;
} finally {
this.isLoading = false;
}
},
async removeItem(cartItem, rowId, index = 0) {
try {
this.isLoading = true;
const formData = new FormData();
formData.append('key', rowId);
await cartRemoveItem(formData);
useYaMetrikaStore().dataLayerPush({
"ecommerce": {
"currencyCode": useSettingsStore().currency_code,
"remove": {
"products": [
{
"id": cartItem.product_id,
"name": cartItem.name,
"quantity": cartItem.quantity,
"position": index
}
]
}
}
});
await this.getProducts();
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
},
async setQuantity(cartId, quantity) {
try {
this.isLoading = true;
const formData = new FormData();
formData.append(`quantity[${cartId}]`, quantity);
await cartEditItem(formData);
await this.getProducts();
} catch (error) {
console.log(error);
} finally {
this.isLoading = false;
}
},
async applyCoupon() {
try {
this.isLoading = true;
this.error_warning = '';
const response = await setCoupon(this.coupon);
if (response.error) {
this.error_warning = response.error;
} else {
await this.getProducts();
}
} catch (error) {
console.log(error);
this.error_warning = 'Возникла ошибка';
} finally {
this.isLoading = false;
}
},
async applyVoucher() {
try {
this.isLoading = true;
this.error_warning = '';
const response = await setVoucher(this.voucher);
if (response.error) {
this.error_warning = response.error;
} else {
await this.getProducts();
}
} catch (error) {
console.log(error);
this.error_warning = 'Возникла ошибка';
} finally {
this.isLoading = false;
}
},
},
});

View File

@@ -0,0 +1,61 @@
import {defineStore} from "pinia";
import ftch from "../utils/ftch.js";
export const useCategoriesStore = defineStore('categories', {
state: () => ({
topCategories: [],
categories: [],
isLoading: false,
isCategoriesLoaded: false,
}),
actions: {
async fetchCategories() {
if (this.isCategoriesLoaded === false && this.categories.length === 0) {
try {
this.isLoading = true;
const {data} = await ftch('categoriesList');
this.categories = data;
this.isCategoriesLoaded = true;
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
}
},
async fetchTopCategories() {
try {
this.isLoading = true;
const response = await ftch('categoriesList', {
forMainPage: true,
});
this.topCategories = response.data;
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
},
async findCategoryById(id, list = []) {
if (! id) return null;
if (list && list.length === 0) {
await this.fetchCategories();
list = this.categories;
}
for (const cat of list) {
if (parseInt(cat.id) === parseInt(id)) return cat;
if (cat.children?.length) {
const found = await this.findCategoryById(id, cat.children);
if (found) return found;
}
}
return null;
}
},
});

View File

@@ -0,0 +1,114 @@
import {defineStore} from "pinia";
import {isNotEmpty} from "@/helpers.js";
import {storeOrder} from "@/utils/ftch.js";
import {useCartStore} from "@/stores/CartStore.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {useSettingsStore} from "@/stores/SettingsStore.js";
export const useCheckoutStore = defineStore('checkout', {
state: () => ({
customer: {
firstName: "",
lastName: "",
email: "",
phone: "",
address: "",
comment: "",
tgData: null,
},
order: null,
isLoading: false,
validationErrors: {},
}),
getters: {
hasError: (state) => {
return (field) => isNotEmpty(state.validationErrors[field]);
},
},
actions: {
async makeOrder() {
try {
this.isLoading = true;
const data = window.Telegram.WebApp.initDataUnsafe;
console.log("Allows write to PM: ", data.user.allows_write_to_pm);
if (! data.user.allows_write_to_pm) {
console.log("Sending request");
const granted = await new Promise(resolve => {
window.Telegram.WebApp.requestWriteAccess((granted) => {
resolve(granted);
});
});
if (granted) {
data.user.allows_write_to_pm = true;
console.log('Пользователь разрешил отправку сообщений');
} else {
alert('Вы не дали разрешение — бот не сможет отправлять вам уведомления');
}
}
this.customer.tgData = data;
const response = await storeOrder(this.customer);
this.order = response.data;
if (! this.order.id) {
console.debug(response.data);
throw new Error('Ошибка создания заказа.');
}
const yaMetrika = useYaMetrikaStore();
yaMetrika.reachGoal(YA_METRIKA_GOAL.ORDER_CREATED_SUCCESS, {
price: this.order?.final_total_numeric,
currency: this.order?.currency,
});
yaMetrika.dataLayerPush({
"ecommerce": {
"currencyCode": useSettingsStore().currency_code,
"purchase": {
"actionField": {
"id": this.order.id,
'revenue': this.order?.final_total_numeric,
},
"products": this.order.products ? this.order.products.map((product, index) => {
return {
id: product.product_id,
name: product.name,
price: product.total_numeric,
position: index,
quantity: product.quantity,
};
}) : [],
}
}
});
await window.Telegram.WebApp.HapticFeedback.notificationOccurred('success');
await useCartStore().getProducts();
} catch (error) {
if (error.response?.status === 422) {
this.validationErrors = error.response._data.data;
} else {
console.error('Server error', error);
}
window.Telegram.WebApp.HapticFeedback.notificationOccurred('error');
throw error;
} finally {
this.isLoading = false;
}
},
clearError(field) {
this.validationErrors[field] = null;
},
},
});

View File

@@ -0,0 +1,41 @@
import {defineStore} from "pinia";
import {getFiltersForMainPage} from "@/utils/ftch.js";
import {md5} from "js-md5";
export const useProductFiltersStore = defineStore('product_filters', {
state: () => ({
isLoading: false,
draft: {},
applied: {},
default: {},
fullPath: '',
}),
getters: {
paramsHashForRouter: (state) => md5(JSON.stringify({ filters: state.applied })),
isFiltersChanged: (state) =>
md5(JSON.stringify({ filters: state.applied })) !== md5(JSON.stringify({ filters: state.default })),
},
actions: {
async fetchFiltersForMainPage() {
if (this.isLoading) return;
try {
this.isLoading = true;
const response = await getFiltersForMainPage();
this.default = response.data;
return response.data;
} catch (error) {
console.log(error);
} finally {
this.isLoading = false;
}
},
clear() {
this.filters = {};
}
},
});

View File

@@ -0,0 +1,122 @@
import {defineStore} from "pinia";
import ftch from "@/utils/ftch.js";
import {md5} from 'js-md5';
import {toRaw} from "vue";
export const useProductsStore = defineStore('products', {
state: () => ({
products: {
data: [],
meta: {
hasMore: true,
},
},
filters: null,
filtersFullUrl: '',
search: '',
page: 1,
isLoading: false,
isLoadingMore: false,
loadFinished: false,
savedScrollY: 0,
currentLoadedParamsHash: null,
}),
getters: {
paramsHash: (state) => md5(JSON.stringify(toRaw(state.getParams()))),
paramsHashForRouter: (state) => md5(JSON.stringify({
search: state.search,
filters: toRaw(state.filters),
})),
},
actions: {
getParams() {
return {
page: this.page,
search: this.search,
filters: toRaw(this.filters),
};
},
async fetchProducts() {
try {
console.debug('Current params hash: ', this.currentLoadedParamsHash);
if (this.products.data.length > 0 && this.paramsHash === this.currentLoadedParamsHash) {
console.debug('Loading products from cache');
return new Promise((resolve, reject) => {
resolve(this.products);
});
}
console.debug('Requested param cache: ', this.paramsHash);
console.debug('Invalidate cache. Fetch products from server.', this.getParams());
const response = await ftch('products', null, this.getParams());
this.currentLoadedParamsHash = this.paramsHash;
console.debug('Products loaded from server.');
console.debug('New params hash: ', this.currentLoadedParamsHash);
return {
meta: response.meta,
data: response.data,
};
} catch (error) {
console.error("Failed to load products");
console.error(error);
} finally {
}
},
async loadProducts(filters = null) {
if (this.isLoading) return;
try {
console.debug('Load products with filters', filters);
this.reset();
this.isLoading = true;
this.page = 1;
this.loadFinished = false;
this.search = '';
this.filters = filters;
this.products = await this.fetchProducts();
} catch (e) {
console.error('Ошибка загрузки', e);
} finally {
this.isLoading = false;
this.loadFinished = true;
}
},
async loadMore() {
if (this.isLoading || this.isLoadingMore || this.products.meta.hasMore === false) return;
try {
this.isLoadingMore = true;
this.page++;
console.debug('Load more products for page: ', this.page);
const response = await this.fetchProducts();
this.products.meta = response.meta;
this.products.data.push(...response.data);
} catch (e) {
console.error('Ошибка загрузки', e);
} finally {
this.isLoadingMore = false;
this.loadFinished = true;
this.isLoading = false;
}
},
reset() {
this.isLoading = false;
this.page = 1;
this.loadFinished = false;
this.search = '';
this.products = {
data: [],
meta: {
hasMore: true,
},
};
},
},
});

View File

@@ -0,0 +1,56 @@
import {defineStore} from "pinia";
import ftch from "@/utils/ftch.js";
import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
export const useSearchStore = defineStore('search', {
state: () => ({
search: '',
page: 1,
products: {
data: [],
meta: {},
},
isLoading: false,
isSearchPerformed: false,
}),
actions: {
reset() {
this.search = '';
this.isSearchPerformed = false;
this.isLoading = false;
this.page = 1;
this.products = {
data: [],
meta: {},
};
},
async performSearch() {
if (!this.search) {
return this.reset();
}
useYaMetrikaStore().reachGoal(YA_METRIKA_GOAL.PERFORM_SEARCH, {
keyword: this.search,
});
try {
this.isLoading = true;
this.products = await ftch('products', {
page: this.page,
perPage: 10,
search: this.search,
});
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
this.isSearchPerformed = true;
}
},
},
});

View File

@@ -0,0 +1,56 @@
import {defineStore} from "pinia";
import {fetchSettings} from "@/utils/ftch.js";
export const useSettingsStore = defineStore('settings', {
state: () => ({
app_enabled: true,
app_debug: false,
store_enabled: true,
app_name: 'OpenCart Telegram магазин',
app_icon: '',
app_icon192: '',
app_icon180: '',
app_icon152: '',
app_icon120: '',
manifest_url: null,
night_auto: true,
ya_metrika_enabled: false,
feature_coupons: false,
feature_vouchers: false,
currency_code: null,
theme: {
light: 'light', dark: 'dark', variables: {
'--product_list_title_max_lines': 2,
}
},
texts: {
no_more_products: 'Нет товаров',
empty_cart: 'Корзина пуста',
order_created_success: 'Заказ успешно оформлен.',
},
}),
actions: {
async load() {
console.log('Load settings');
const settings = await fetchSettings();
this.manifest_url = settings.manifest_url;
this.app_name = settings.app_name;
this.app_icon = settings.app_icon;
this.app_icon192 = settings.app_icon192;
this.app_icon180 = settings.app_icon180;
this.app_icon152 = settings.app_icon152;
this.app_icon120 = settings.app_icon120;
this.theme.light = settings.theme_light;
this.theme.dark = settings.theme_dark;
this.ya_metrika_enabled = settings.ya_metrika_enabled;
this.app_enabled = settings.app_enabled;
this.app_debug = settings.app_debug;
this.store_enabled = settings.store_enabled;
this.feature_coupons = settings.feature_coupons;
this.feature_vouchers = settings.feature_vouchers;
this.currency_code = settings.currency_code;
this.texts = settings.texts;
}
}
});

View File

@@ -0,0 +1,130 @@
import {defineStore} from "pinia";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import sha256 from 'crypto-js/sha256';
import {toRaw} from "vue";
export const useYaMetrikaStore = defineStore('ya_metrika', {
state: () => ({
queue: [],
prevPath: null,
}),
actions: {
pushHit(url, params = {}) {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
const fullUrl = `/#${url}`;
params.referer = params.referer ?? this.prevPath;
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
console.debug('[ym] Hit ', fullUrl);
console.debug('[ym] ID ', window.YA_METRIKA_ID);
console.debug('[ym] params ', params);
window.ym(window.YA_METRIKA_ID, 'hit', fullUrl, params);
} else {
console.debug('[ym] Yandex Metrika is not initialized. Pushed to queue.');
this.queue.push({
event: 'hit',
payload: {
fullUrl,
params,
}
});
}
},
reachGoal(target, params = {}) {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
console.debug('[ym] reachGoal ', target, ' params: ', params);
window.ym(window.YA_METRIKA_ID, 'reachGoal', target, params);
} else {
console.debug('[ym] Yandex Metrika is not initialized. Pushed to queue.');
this.queue.push({
event: 'reachGoal',
payload: {
target,
params
},
});
}
},
initUserParams() {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
if (typeof window.ym === 'function' && window.YA_METRIKA_ID !== undefined) {
let tgID = null;
if (window?.Telegram?.WebApp?.initDataUnsafe?.user?.id) {
tgID = sha256(window.Telegram.WebApp.initDataUnsafe.user.id).toString();
}
const userParams = {
tg_id: tgID,
language: window.Telegram?.WebApp?.initDataUnsafe?.user?.language_code || 'unknown',
platform: window.Telegram?.WebApp?.platform || 'unknown',
};
window.ym(window.YA_METRIKA_ID, 'userParams', userParams);
console.debug('[ym] User params initialized: ', userParams);
} else {
console.debug('[ym] Yandex Metrika is not initialized. Could not init user params.');
}
},
processQueue() {
if (this.queue.length === 0) {
return;
}
console.debug('[ym] Start processing queue. Size: ', this.queue.length);
while (this.queue.length > 0) {
const item = this.queue.shift();
if (item.event === 'hit') {
console.debug('[ym] Queue ', toRaw(item));
window.ym(window.YA_METRIKA_ID, 'hit', item.payload.fullUrl, item.payload.params);
} else if (item.event === 'reachGoal') {
window.ym(window.YA_METRIKA_ID, 'reachGoal', item.payload.target, item.payload.params);
} else if (item.event === 'dataLayer') {
console.debug('[ym] queue dataLayer push: ', item.payload);
window.dataLayer.push(item.payload);
} else {
console.error('[ym] Unsupported queue event: ', item.event);
}
}
console.debug('[ym] Queue processing complete. Size: ', this.queue.length);
},
dataLayerPush(object) {
if (!useSettingsStore().ya_metrika_enabled) {
console.debug('[ym] Yandex Metrika disabled in settings.');
return;
}
if (Array.isArray(window.dataLayer)) {
console.debug('[ym] dataLayer push: ', object);
window.dataLayer.push(object);
} else {
console.debug('[ym] dataLayer inaccessible. Put to queue');
this.queue.push({
event: 'dataLayer',
payload: object,
});
}
}
},
});