feat: search component and loading splashscreen
This commit is contained in:
@@ -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
16
spa/src/AppLoading.vue
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|||||||
38
spa/src/components/SearchInput.vue
Normal file
38
spa/src/components/SearchInput.vue
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
51
spa/src/stores/SearchStore.js
Normal file
51
spa/src/stores/SearchStore.js
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
96
spa/src/views/Search.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user