feat: search component and loading splashscreen

This commit is contained in:
2025-08-08 14:36:05 +03:00
parent a8bb5eb493
commit 2fb841ef08
12 changed files with 217 additions and 35 deletions

View File

@@ -48,7 +48,7 @@ class ProductsHandler
{ {
$languageId = 1; $languageId = 1;
$page = $request->get('page', 1); $page = $request->get('page', 1);
$perPage = 6; $perPage = min((int)$request->get('perPage', 6), 15);
$categoryId = (int) $request->get('categoryId', 0); $categoryId = (int) $request->get('categoryId', 0);
$search = trim($request->get('search', '')); $search = trim($request->get('search', ''));

16
spa/src/AppLoading.vue Normal file
View File

@@ -0,0 +1,16 @@
<template>
<div style="z-index: 99999" class="fixed top-0 left-0 w-full h-full bg-base-100">
<div class="flex flex-col items-center justify-center h-full">
<span class="loading loading-infinity loading-xl"></span>
<h1>Загрузка приложения...</h1>
</div>
</div>
</template>
<script setup>
const props = defineProps({
error: Error,
});
</script>

View File

@@ -1,31 +1,5 @@
<template> <template>
<div class="mx-auto max-w-2xl px-4 py-4 sm:px-6 sm:py-6 lg:max-w-7xl lg:px-8"> <div class="mx-auto max-w-2xl px-4 py-4 sm:px-6 sm:py-6 lg:max-w-7xl lg:px-8">
<div
class="search-wrapper p-2 w-full"
>
<label class="input w-full">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
v-model="productsStore.search"
@input="debouncedSearch"
type="search"
class="grow input-lg"
placeholder="Поиск по магазину"
/>
</label>
</div>
<h2 class="text-lg font-bold mb-5 text-center">{{ productsStore.products.meta.currentCategoryName }}</h2> <h2 class="text-lg font-bold mb-5 text-center">{{ productsStore.products.meta.currentCategoryName }}</h2>
<div v-if="productsStore.products.data.length > 0"> <div v-if="productsStore.products.data.length > 0">
@@ -75,7 +49,7 @@
import NoProducts from "@/components/NoProducts.vue"; import NoProducts from "@/components/NoProducts.vue";
import ProductImageSwiper from "@/components/ProductImageSwiper.vue"; import ProductImageSwiper from "@/components/ProductImageSwiper.vue";
import {useProductsStore} from "@/stores/ProductsStore.js"; import {useProductsStore} from "@/stores/ProductsStore.js";
import {useDebounceFn, useInfiniteScroll} from '@vueuse/core'; import {useInfiniteScroll} from '@vueuse/core';
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
import {useSettingsStore} from "@/stores/SettingsStore.js"; import {useSettingsStore} from "@/stores/SettingsStore.js";
import {nextTick, onMounted, onUnmounted, ref, watch} from "vue"; import {nextTick, onMounted, onUnmounted, ref, watch} from "vue";
@@ -85,12 +59,6 @@ const categoryId = route.params.category_id ?? null;
const productsStore = useProductsStore(); const productsStore = useProductsStore();
const settings = useSettingsStore(); const settings = useSettingsStore();
const bottom = ref(null); const bottom = ref(null);
const debounceMs = 500;
const debouncedSearch = useDebounceFn(() => {
productsStore.reset();
loadMore();
}, debounceMs);
function haptic() { function haptic() {
window.Telegram.WebApp.HapticFeedback.selectionChanged(); window.Telegram.WebApp.HapticFeedback.selectionChanged();

View File

@@ -0,0 +1,38 @@
<template>
<div class="search-wrapper px-5 w-full">
<label class="input w-full">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
readonly
class="grow input-lg"
placeholder="Поиск по магазину"
@click="showSearchPage"
/>
</label>
</div>
</template>
<script setup>
import {useRouter} from "vue-router";
import {useSearchStore} from "@/stores/SearchStore.js";
const router = useRouter();
function showSearchPage() {
router.push({name: 'search'});
useSearchStore().reset();
}
</script>

View File

@@ -15,6 +15,7 @@ import {injectYaMetrika} from "@/utils/yaMetrika.js";
import { register } from 'swiper/element/bundle'; import { register } from 'swiper/element/bundle';
import 'swiper/element/bundle'; import 'swiper/element/bundle';
import 'swiper/css/bundle'; import 'swiper/css/bundle';
import AppLoading from "@/AppLoading.vue";
register(); register();
const pinia = createPinia(); const pinia = createPinia();
@@ -26,6 +27,9 @@ app
const settings = useSettingsStore(); const settings = useSettingsStore();
const appLoading = createApp(AppLoading);
appLoading.mount('#app');
settings.load() settings.load()
.then(() => { .then(() => {
if (settings.app_enabled === false) { if (settings.app_enabled === false) {
@@ -33,6 +37,7 @@ settings.load()
} }
}) })
.then(() => { .then(() => {
console.log('Set theme attributes');
document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]); document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]);
if (settings.night_auto) { if (settings.night_auto) {
window.Telegram.WebApp.onEvent('themeChanged', function () { window.Telegram.WebApp.onEvent('themeChanged', function () {
@@ -41,12 +46,13 @@ settings.load()
} }
}) })
.then(() => { .then(() => {
console.log('Load front page categories and products.');
const categoriesStore = useCategoriesStore(); const categoriesStore = useCategoriesStore();
categoriesStore.fetchTopCategories(); categoriesStore.fetchTopCategories();
categoriesStore.fetchCategories(); categoriesStore.fetchCategories();
}) })
.then(() => new AppMetaInitializer(settings).init()) .then(() => new AppMetaInitializer(settings).init())
.then(() => app.mount('#app')) .then(() => { appLoading.unmount(); app.mount('#app'); })
.then(() => window.Telegram.WebApp.ready()) .then(() => window.Telegram.WebApp.ready())
.then(() => settings.ya_metrika_enabled && injectYaMetrika()) .then(() => settings.ya_metrika_enabled && injectYaMetrika())
.catch(error => { .catch(error => {

View File

@@ -6,6 +6,7 @@ import Cart from "./views/Cart.vue";
import Products from "@/views/Products.vue"; import Products from "@/views/Products.vue";
import Checkout from "@/views/Checkout.vue"; import Checkout from "@/views/Checkout.vue";
import OrderCreated from "@/views/OrderCreated.vue"; import OrderCreated from "@/views/OrderCreated.vue";
import Search from "@/views/Search.vue";
const routes = [ const routes = [
{path: '/', name: 'home', component: Home}, {path: '/', name: 'home', component: Home},
@@ -16,6 +17,7 @@ const routes = [
{path: '/cart', name: 'cart', component: Cart}, {path: '/cart', name: 'cart', component: Cart},
{path: '/checkout', name: 'checkout', component: Checkout}, {path: '/checkout', name: 'checkout', component: Checkout},
{path: '/success', name: 'order_created', component: OrderCreated}, {path: '/success', name: 'order_created', component: OrderCreated},
{path: '/search', name: 'search', component: Search},
]; ];
export const router = createRouter({ export const router = createRouter({

View File

@@ -37,6 +37,7 @@ export const useProductsStore = defineStore('products', {
this.page = 1; this.page = 1;
this.hasMore = true; this.hasMore = true;
this.loadFinished = false; this.loadFinished = false;
this.search = '';
this.products = { this.products = {
data: [], data: [],
meta: {}, meta: {},

View File

@@ -0,0 +1,51 @@
import {defineStore} from "pinia";
import ftch from "@/utils/ftch.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();
}
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

@@ -22,6 +22,7 @@ export const useSettingsStore = defineStore('settings', {
actions: { actions: {
async load() { async load() {
console.log('Load settings');
const settings = await fetchSettings(); const settings = await fetchSettings();
this.manifest_url = settings.manifest_url; this.manifest_url = settings.manifest_url;
this.app_name = settings.app_name; this.app_name = settings.app_name;

View File

@@ -6,6 +6,7 @@ class AppMetaInitializer {
} }
public init() { public init() {
console.log('Init app meta');
document.title = this.settings.app_name; document.title = this.settings.app_name;
this.setMeta('application-name', this.settings.app_name); this.setMeta('application-name', this.settings.app_name);
this.setMeta('apple-mobile-web-app-title', this.settings.app_name); this.setMeta('apple-mobile-web-app-title', this.settings.app_name);

View File

@@ -1,6 +1,7 @@
<template> <template>
<div ref="goodsRef" class="safe-top"> <div ref="goodsRef" class="safe-top">
<CategoriesInline/> <CategoriesInline/>
<SearchInput/>
<ProductsList/> <ProductsList/>
</div> </div>
</template> </template>
@@ -8,4 +9,5 @@
<script setup> <script setup>
import ProductsList from "@/components/ProductsList.vue"; import ProductsList from "@/components/ProductsList.vue";
import CategoriesInline from "../components/CategoriesInline.vue"; import CategoriesInline from "../components/CategoriesInline.vue";
import SearchInput from "@/components/SearchInput.vue";
</script> </script>

96
spa/src/views/Search.vue Normal file
View File

@@ -0,0 +1,96 @@
<template>
<div class="max-w-3xl mx-auto p-4 space-y-6 pb-20 safe-top">
<h2 class="text-2xl mb-3">Поиск</h2>
<div class="w-full">
<label class="input w-full">
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
ref="searchInput"
type="search"
class="grow input-lg"
placeholder="Поиск по магазину"
v-model="searchStore.search"
@search="debouncedSearch"
@input="debouncedSearch"
/>
</label>
</div>
<div v-if="searchStore.isLoading === false && searchStore.products.data.length > 0">
<RouterLink
v-for="product in searchStore.products.data"
:key="product.id"
class="flex mb-5"
:to="{name: 'product.show', params: {id: product.id}}"
>
<div v-if="product.images && product.images.length > 0" class="avatar">
<div class="w-24 rounded">
<img :src="product.images[0].url" :alt="product.images[0].alt"/>
</div>
</div>
<div class="ml-5 p-0">
<h2 class="card-title">{{ product.name }}</h2>
<p>{{ product.price }}</p>
</div>
</RouterLink>
</div>
<div v-if="searchStore.isLoading === true">
<div v-for="n in 3" class="flex w-full gap-4 mb-3">
<div class="skeleton h-32 w-32"></div>
<div class="flex flex-col gap-2 w-full">
<div class="skeleton h-4 w-full"></div>
<div class="skeleton h-4 w-28"></div>
<div class="skeleton h-4 w-28"></div>
</div>
</div>
</div>
<div
v-if="searchStore.isSearchPerformed && searchStore.isLoading === false && searchStore.products.data.length === 0"
class="flex flex-col items-center justify-center text-center py-16"
>
<span class="text-5xl mb-4">🛒</span>
<h2 class="text-xl font-semibold mb-2">Товары не найдены</h2>
<p class="text-sm mb-4">Попробуйте изменить или уточнить запрос</p>
</div>
</div>
</template>
<script setup>
import {useSearchStore} from "@/stores/SearchStore.js";
import {useDebounceFn} from "@vueuse/core";
import {nextTick, onMounted, onUnmounted, ref} from "vue";
const searchStore = useSearchStore();
const searchInput = ref(null);
const debouncedSearch = useDebounceFn(() => searchStore.performSearch(), 500);
function handleClickOutside(e) {
if (!e.target.closest('input')) {
document.activeElement?.blur();
}
}
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
onMounted(() => {
document.addEventListener('click', handleClickOutside);
nextTick(() => searchInput.value.focus());
});
</script>