feat: product options, speedup home page, themes

This commit is contained in:
Nikita Kiselev
2025-07-21 13:37:09 +03:00
parent 51ce6ed959
commit e3cc0d4b10
18 changed files with 181 additions and 79 deletions

View File

@@ -1,10 +1,9 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="ru" data-theme="cyberpunk">
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/vite.svg"/> <link rel="icon" type="image/svg+xml" href="/vite.svg"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Vite + Vue</title> <title>Vite + Vue</title>
</head> </head>
<body> <body>

View File

@@ -29,7 +29,11 @@ watch(
backButton.hide?.(); backButton.hide?.();
} else { } else {
backButton.show?.(); backButton.show?.();
backButton.onClick?.(() => router.back()); backButton.onClick?.(() => {
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
router.back();
});
} }
}, },
{immediate: true, deep: true} {immediate: true, deep: true}

View File

@@ -7,22 +7,13 @@
Каталог Каталог
</RouterLink> </RouterLink>
<RouterLink v-for="category in categories" class="btn" :to="`/category/${category.id}`"> <RouterLink v-for="category in categoriesStore.topCategories" class="btn" :to="`/category/${category.id}`">
{{ category.name }} {{ category.name }}
</RouterLink> </RouterLink>
</div> </div>
</template> </template>
<script setup> <script setup>
import {onMounted, ref} from "vue"; import {useCategoriesStore} from "@/stores/CategoriesStore.js";
import ftch from "../utils/ftch.js"; const categoriesStore = useCategoriesStore();
const categories = ref([]);
onMounted(async () => {
const {data} = await ftch('categoriesList', {
perPage: 7,
});
categories.value = data;
});
</script> </script>

View File

@@ -4,8 +4,9 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<label <label
v-for="value in model.values" v-for="value in model.values"
class="group relative flex items-center justify-center rounded-md border border-gray-300 bg-white p-2 has-checked:border-indigo-600 has-checked:bg-indigo-600 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600 has-disabled:border-gray-400 has-disabled:bg-gray-200 has-disabled:opacity-25"> class="group relative flex items-center justify-center btn btn-soft btn-secondary btn-sm"
:class="value.selected ? 'btn-active' : ''"
>
<input <input
type="checkbox" type="checkbox"
:value="value.product_option_value_id" :value="value.product_option_value_id"
@@ -36,6 +37,8 @@ function select(toggledValue) {
} }
}); });
model.value.value = model.value.values.filter(item => item.selected === true);
emit('update:modelValue', model.value); emit('update:modelValue', model.value);
} }
</script> </script>

View File

@@ -3,7 +3,9 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<label <label
v-for="value in model.values" v-for="value in model.values"
class="group relative flex items-center justify-center rounded-md border border-gray-300 bg-base-200 p-2 has-checked:border-indigo-600 has-checked:bg-primary has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-indigo-600 has-disabled:border-gray-400 has-disabled:bg-gray-200 has-disabled:opacity-25"> class="group relative flex items-center justify-center btn btn-soft btn-secondary btn-sm"
:class="value.selected ? 'btn-active' : ''"
>
<input <input
type="radio" type="radio"
@@ -14,7 +16,7 @@
class="absolute inset-0 appearance-none focus:outline-none disabled:cursor-not-allowed" class="absolute inset-0 appearance-none focus:outline-none disabled:cursor-not-allowed"
/> />
<span class="text-xs font-medium group-has-checked:text-white"> <span class="text-xs font-medium">
{{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span> {{ value.name }}<span v-if="value.price"> ({{ value.price_prefix }}{{ value.price }})</span>
</span> </span>
</label> </label>
@@ -33,6 +35,8 @@ function select(selectedValue) {
value.selected = (value === selectedValue); value.selected = (value === selectedValue);
}); });
model.value.value = selectedValue;
emit('update:modelValue', model); emit('update:modelValue', model);
} }
</script> </script>

View File

@@ -5,6 +5,7 @@
class="select" class="select"
@change="onChange" @change="onChange"
> >
<option value="" disabled>Выберите значение</option>
<option <option
v-for="value in model.values" v-for="value in model.values"
:key="value.product_option_value_id" :key="value.product_option_value_id"
@@ -30,6 +31,8 @@ function onChange(event) {
value.selected = (value.product_option_value_id === selectedId); value.selected = (value.product_option_value_id === selectedId);
}); });
model.value.value = model.value.values.find(value => value.product_option_value_id === selectedId);
emit('update:modelValue', model.value); emit('update:modelValue', model.value);
} }
</script> </script>

View File

@@ -10,10 +10,16 @@
</div> </div>
<template v-else> <template v-else>
<h2 class="text-lg font-bold mb-5 text-center">{{ productsMeta.currentCategoryName }}</h2> <h2 class="text-lg font-bold mb-5 text-center">{{ meta.currentCategoryName }}</h2>
<div v-if="products.length > 0" class="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"> <div v-if="products.length > 0" class="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
<RouterLink v-for="product in products" :key="product.id" class="group" :to="`/product/${product.id}`"> <RouterLink
v-for="product in products"
:key="product.id"
class="group"
:to="`/product/${product.id}`"
@click="haptic"
>
<ProductImageSwiper :images="product.images"/> <ProductImageSwiper :images="product.images"/>
<h3 class="mt-4 text-sm">{{ product.name }}</h3> <h3 class="mt-4 text-sm">{{ product.name }}</h3>
<p class="mt-1 text-lg font-medium">{{ product.price }}</p> <p class="mt-1 text-lg font-medium">{{ product.price }}</p>
@@ -26,21 +32,34 @@
</template> </template>
<script setup> <script setup>
import {onMounted, ref} from "vue"; import {ref} from "vue";
import {useHapticFeedback} from 'vue-tg'; import {useHapticFeedback} from 'vue-tg';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import ftch from "../utils/ftch.js";
import NoProducts from "../components/NoProducts.vue"; import NoProducts from "../components/NoProducts.vue";
import ProductImageSwiper from "../components/ProductImageSwiper.vue"; import ProductImageSwiper from "../components/ProductImageSwiper.vue";
const hapticFeedback = useHapticFeedback(); const hapticFeedback = useHapticFeedback();
const router = useRouter();
const route = useRoute();
const categoryId = route.params.id;
const isLoading = ref(true); const props = defineProps({
const products = ref([]); products: {
const productsMeta = ref([]); type: Array,
default: () => [],
},
meta: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
}
});
function haptic() {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
}
const carouselRef = ref(); const carouselRef = ref();
let lastScrollLeft = 0; let lastScrollLeft = 0;
@@ -53,13 +72,4 @@ function onScroll(e) {
lastScrollLeft = scrollLeft; lastScrollLeft = scrollLeft;
} }
} }
onMounted(async () => {
const {data, meta} = await ftch('products', {
categoryId: categoryId,
});
productsMeta.value = meta;
products.value = data;
isLoading.value = false;
});
</script> </script>

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="flex items-center text-center"> <div class="flex items-center text-center">
<button class="btn btn-lg btn-neutral" @click="inc">-</button> <button class="btn btn-lg" @click="inc">-</button>
<div class="w-10 h-10 flex items-center justify-center bg-neutral font-bold">{{ model }}</div> <div class="w-10 h-10 flex items-center justify-center font-bold">{{ model }}</div>
<button class="btn btn-lg btn-neutral" @click="dec">+</button> <button class="btn btn-lg" @click="dec">+</button>
</div> </div>
</template> </template>

View File

@@ -5,22 +5,38 @@ import { VueTelegramPlugin } from 'vue-tg';
import { router } from './router'; import { router } from './router';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
const config = {
night_auto: false,
theme: {
light: 'fantasy',
dark: 'dark',
}
};
const pinia = createPinia(); const pinia = createPinia();
const app = createApp(App); const app = createApp(App);
app app
.use(pinia) .use(pinia)
.use(router) .use(router)
.use(VueTelegramPlugin); .use(VueTelegramPlugin);
app.mount('#app'); app.mount('#app');
import { useMiniApp, useTheme } from 'vue-tg'; const productsStore = useProductsStore();
productsStore.fetchHomeProducts();
const categoriesStore = useCategoriesStore();
categoriesStore.fetchTopCategories();
const theme = useTheme(); import {useProductsStore} from "@/stores/ProductsStore.js";
const tg = useMiniApp(); import {useCategoriesStore} from "@/stores/CategoriesStore.js";
theme.onChange(() => { if (config.night_auto) {
document.documentElement.setAttribute('data-theme', theme.colorScheme.value); window.Telegram.WebApp.onEvent('themeChanged', function () {
}); document.documentElement.setAttribute('data-theme', config.theme[this.colorScheme]);
});
} else {
document.documentElement.setAttribute('data-theme', config.theme.light);
}
document.documentElement.setAttribute('data-theme', theme.colorScheme.value);
window.Telegram.WebApp.ready(); window.Telegram.WebApp.ready();

View File

@@ -2,7 +2,7 @@ import {createMemoryHistory, createRouter} from 'vue-router';
import Home from './views/Home.vue'; import Home from './views/Home.vue';
import Product from './views/Product.vue'; import Product from './views/Product.vue';
import CategoriesList from "./views/CategoriesList.vue"; import CategoriesList from "./views/CategoriesList.vue";
import ProductsList from "./views/ProductsList.vue"; import ProductsList from "@/components/ProductsList.vue";
import Cart from "./views/Cart.vue"; import Cart from "./views/Cart.vue";
const routes = [ const routes = [

View File

@@ -0,0 +1,25 @@
import {defineStore} from "pinia";
import ftch from "../utils/ftch.js";
export const useCategoriesStore = defineStore('categories', {
state: () => ({
topCategories: [],
isLoading: false,
}),
actions: {
async fetchTopCategories() {
try {
this.isLoading = true;
const response = await ftch('categoriesList', {
perPage: 7,
});
this.topCategories = response.data;
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
},
},
});

View File

@@ -0,0 +1,25 @@
import {defineStore} from "pinia";
import ftch from "../utils/ftch.js";
export const useProductsStore = defineStore('products', {
state: () => ({
homeProducts: {
data: [],
meta: {},
},
isLoading: false,
}),
actions: {
async fetchHomeProducts() {
try {
this.isLoading = true;
this.homeProducts = await ftch('products');
} catch (error) {
console.error(error);
} finally {
this.isLoading = false;
}
},
},
});

View File

@@ -1,7 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui" { @plugin "daisyui" {
themes: light --default, dark --prefersdark; themes: all;
} }
html, body { html, body {

View File

@@ -1,18 +1,24 @@
<template> <template>
<div ref="goodsRef"> <div ref="goodsRef">
<CategoriesInline/> <CategoriesInline/>
<ProductsList/> <ProductsList
:products="productsStore.homeProducts.data"
:meta="productsStore.homeProducts.meta"
:isLoading="productsStore.isLoading"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import {ref} from "vue"; import {ref} from "vue";
import ProductsList from "@/components/ProductsList.vue";
import CategoriesInline from "../components/CategoriesInline.vue";
import {useProductsStore} from "@/stores/ProductsStore.js";
const productsStore = useProductsStore();
const goodsRef = ref(); const goodsRef = ref();
function scrollToProducts() { function scrollToProducts() {
goodsRef.value?.scrollIntoView({ behavior: 'smooth' }); goodsRef.value?.scrollIntoView({ behavior: 'smooth' });
} }
import ProductsList from "./ProductsList.vue";
import CategoriesInline from "../components/CategoriesInline.vue";
</script> </script>

View File

@@ -1,11 +1,11 @@
<template> <template>
<div> <div class="pb-10">
<div> <div>
<ProductImageSwiper :images="product.images"/> <ProductImageSwiper :images="product.images"/>
<!-- Product info --> <!-- Product info -->
<div <div
class="mx-auto max-w-2xl px-4 pt-3 pb-16 sm:px-6 lg:grid lg:max-w-7xl lg:grid-cols-3 lg:grid-rows-[auto_auto_1fr] lg:gap-x-8 lg:px-8 lg:pt-16 lg:pb-24"> class="mx-auto max-w-2xl px-4 pt-3 pb-16 sm:px-6 lg:grid lg:max-w-7xl lg:grid-cols-3 lg:grid-rows-[auto_auto_1fr] lg:gap-x-8 lg:px-8 lg:pt-16 lg:pb-24 rounded-t-lg">
<div class="lg:col-span-2 lg:border-r lg:pr-8"> <div class="lg:col-span-2 lg:border-r lg:pr-8">
<h1 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ product.name }}</h1> <h1 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ product.name }}</h1>
</div> </div>
@@ -34,15 +34,22 @@
</div> </div>
</div> </div>
<div class="px-4 pb-10 pt-4 fixed bottom-0 left-0 w-full bg-info-content z-50 flex justify-between gap-2 border-t-1 border-t-success-content"> <div v-if="product.id" class="px-4 pb-10 pt-4 fixed bottom-0 left-0 w-full bg-base-200 z-50 flex justify-between gap-2 border-t-1 border-t-base-300">
<div class="flex-1">
<button <button
class="btn btn-lg flex-1" class="btn btn-lg w-full"
:class="isInCartNow ? 'btn-success' : 'btn-primary'" :class="isInCartNow ? 'btn-success' : 'btn-primary'"
:disabled="canAddToCart === false"
@click="actionBtnClick" @click="actionBtnClick"
> >
{{ buttonText }} <span>{{ buttonText }}</span><br>
</button> </button>
<div v-if="canAddToCart === false" class="text-error text-center text-xs mt-1">
Выберите обязательные опции
</div>
</div>
<Quantity <Quantity
v-if="quantity > 0" v-if="quantity > 0"
:modelValue="quantity" :modelValue="quantity"
@@ -76,6 +83,22 @@ const buttonText = computed(() => {
: 'Добавить в корзину' : 'Добавить в корзину'
}); });
const canAddToCart = computed(() => {
if (!product.value || product.value.options === undefined || product.value.options?.length === 0) {
return true;
}
const required = product.value.options.filter(item => {
return ['checkbox', 'radio', 'select', 'text', 'textarea'].indexOf(item.type) !== -1
&& item.required === true
&& !item.value;
});
console.log(required);
return required.length === 0;
});
const isInCartNow = computed(() => { const isInCartNow = computed(() => {
return cart.hasProduct(productId.value); return cart.hasProduct(productId.value);
}); });
@@ -108,17 +131,4 @@ onMounted(async () => {
const {data} = await $fetch(`/index.php?route=extension/tgshop/handle&api_action=product_show&id=${productId.value}`); const {data} = await $fetch(`/index.php?route=extension/tgshop/handle&api_action=product_show&id=${productId.value}`);
product.value = data; product.value = data;
}); });
const carouselRef = ref();
let lastScrollLeft = 0;
function onScroll(e) {
const scrollLeft = e.target.scrollLeft;
const delta = Math.abs(scrollLeft - lastScrollLeft);
if (delta > 30) {
hapticFeedback.impactOccurred('soft');
lastScrollLeft = scrollLeft;
}
}
</script> </script>

View File

@@ -1,6 +1,7 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import {fileURLToPath, URL} from 'node:url';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), vue()], plugins: [tailwindcss(), vue()],
@@ -13,6 +14,12 @@ export default defineConfig({
manifest: true, manifest: true,
}, },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: { server: {
host: true, host: true,
allowedHosts: ["tg.nikitakiselev.ru"], allowedHosts: ["tg.nikitakiselev.ru"],