wip: cart

This commit is contained in:
Nikita Kiselev
2025-07-20 22:22:14 +03:00
parent 1ffb1cef12
commit ee67bd55df
12 changed files with 541 additions and 19 deletions

156
spa/package-lock.json generated
View File

@@ -11,6 +11,8 @@
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"ofetch": "^1.4.1", "ofetch": "^1.4.1",
"pinia": "^3.0.3",
"swiper": "^11.2.10",
"vue": "^3.5.17", "vue": "^3.5.17",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue-tg": "^0.9.0-beta.10" "vue-tg": "^0.9.0-beta.10"
@@ -1163,6 +1165,30 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/devtools-kit": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz",
"integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.7",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz",
"integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.17", "version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.17.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.17.tgz",
@@ -1251,6 +1277,15 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/birpc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.5.0.tgz",
"integrity": "sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.25.1", "version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
@@ -1314,6 +1349,21 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
"license": "MIT",
"dependencies": {
"is-what": "^4.1.8"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -1482,6 +1532,24 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/is-what": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
"license": "MIT",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
@@ -1749,6 +1817,12 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp": { "node_modules/mkdirp": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
@@ -1816,6 +1890,12 @@
"ufo": "^1.5.4" "ufo": "^1.5.4"
} }
}, },
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1834,6 +1914,36 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pinia": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.2"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz",
"integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.7"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1869,6 +1979,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.44.2", "version": "4.44.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz",
@@ -1917,6 +2033,46 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/superjson": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
"integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==",
"license": "MIT",
"dependencies": {
"copy-anything": "^3.0.2"
},
"engines": {
"node": ">=16"
}
},
"node_modules/swiper": {
"version": "11.2.10",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-11.2.10.tgz",
"integrity": "sha512-RMeVUUjTQH+6N3ckimK93oxz6Sn5la4aDlgPzB+rBrG/smPdCTicXyhxa+woIpopz+jewEloiEE3lKo1h9w2YQ==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/swiperjs"
},
{
"type": "open_collective",
"url": "http://opencollective.com/swiper"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",

View File

@@ -12,6 +12,8 @@
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"ofetch": "^1.4.1", "ofetch": "^1.4.1",
"pinia": "^3.0.3",
"swiper": "^11.2.10",
"vue": "^3.5.17", "vue": "^3.5.17",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue-tg": "^0.9.0-beta.10" "vue-tg": "^0.9.0-beta.10"

View File

@@ -1,21 +1,21 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/> <FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
<router-view /> <router-view/>
</div> </div>
</template> </template>
<script setup> <script setup>
import {onMounted, ref, watch} from "vue"; import {onMounted, ref, watch} from "vue";
import { useWebAppViewport, useBackButton } from 'vue-tg'; import {useWebAppViewport, useBackButton} from 'vue-tg';
import { useMiniApp, FullscreenViewport } from 'vue-tg'; import {useMiniApp, FullscreenViewport} from 'vue-tg';
import {useRoute, useRouter} from "vue-router"; import {useRoute, useRouter} from "vue-router";
const tg = useMiniApp(); const tg = useMiniApp();
const platform = ref(); const platform = ref();
platform.value = tg.platform; platform.value = tg.platform;
const { disableVerticalSwipes } = useWebAppViewport(); const {disableVerticalSwipes} = useWebAppViewport();
disableVerticalSwipes(); disableVerticalSwipes();
const router = useRouter(); const router = useRouter();
@@ -32,6 +32,6 @@ watch(
backButton.onClick?.(() => router.back()); backButton.onClick?.(() => router.back());
} }
}, },
{ immediate: true, deep: true } {immediate: true, deep: true}
); );
</script> </script>

63
spa/src/ShoppingCart.js Normal file
View File

@@ -0,0 +1,63 @@
import {reactive} from "vue";
class ShoppingCart {
constructor() {
this.items = reactive([]);
this.storageKey = 'shoppingCart';
this.storage = Telegram.WebApp.DeviceStorage;
this._load()
.then(items => {
this.items = items;
console.log(items);
})
.catch(error => console.log(error));
}
async addItem(productId, productName, quantity, options = {}) {
this.items.push({ productId: productId, productName: productName, quantity, options });
this._save(this.items);
}
has(productId) {
const item = this.getItem(productId);
console.log(item);
return this.getItem(productId) !== null;
}
getItem(productId) {
return this.items.find(item => item.productId === productId) ?? null;
}
getItems() {
return this.items;
}
clear() {
this.storage.deleteItem(this.storageKey)
}
async _load() {
return new Promise((resolve, reject) => {
this.storage.getItem(this.storageKey, (error, value) => {
if (error) {
console.error(error);
reject([]);
}
try {
resolve(value ? JSON.parse(value) : []);
} catch (error) {
console.error(error);
reject([]);
}
});
});
}
_save(items) {
this.storage.setItem(this.storageKey, JSON.stringify(items));
}
}
export default ShoppingCart;

View File

@@ -0,0 +1,93 @@
<template>
<div
v-if="logs.length"
ref="logContainer"
class="fixed bottom-0 left-0 right-0 max-h-60 overflow-y-auto bg-white text-sm font-mono border-t border-gray-300 shadow-lg z-[9999] p-4 space-y-2"
>
<div
v-for="(log, idx) in logs"
:key="idx"
:class="colorClass(log.type)"
class="whitespace-pre-wrap"
>
[{{ log.type.toUpperCase() }}] {{ log.message }}
</div>
</div>
</template>
<script setup>
import {ref, onMounted, nextTick} from 'vue'
const logs = ref([])
const logContainer = ref(null)
function pushLog(type, input) {
let message = ''
let details = ''
if (input instanceof Error) {
message = input.message
details = input.stack
} else if (typeof input === 'string') {
message = input
} else {
try {
message = JSON.stringify(input, null, 2)
} catch {
message = String(input)
}
}
logs.value.push({ type, message, details })
nextTick(() => {
const el = logContainer.value
if (el) el.scrollTop = el.scrollHeight
});
}
function colorClass(type) {
switch (type) {
case 'error': return 'text-red-700'
case 'warn': return 'text-yellow-700'
case 'info': return 'text-blue-700'
default: return 'text-gray-800'
}
}
onMounted(() => {
if (import.meta.env.PROD) return
// Backup originals
const orig = {
log: console.log,
warn: console.warn,
error: console.error,
info: console.info,
}
Object.entries(orig).forEach(([type, fn]) => {
console[type] = (...args) => {
pushLog(type, args.map(toText).join(' '))
fn.apply(console, args)
}
})
window.addEventListener('error', (e) => {
pushLog('error', e.error?.stack || `${e.message} at ${e.filename}:${e.lineno}:${e.colno}`)
})
window.addEventListener('unhandledrejection', (e) => {
pushLog('error', e.reason?.stack || e.reason?.message || String(e.reason))
})
})
function toText(v) {
try {
if (typeof v === 'string') return v
return JSON.stringify(v, null, 2)
} catch {
return String(v)
}
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<swiper
:style="{
'--swiper-navigation-color': '#fff',
'--swiper-pagination-color': '#fff',
}"
:lazy="true"
:pagination="pagination"
:navigation="true"
:modules="modules"
class="mySwiper"
>
<swiper-slide v-for="image in images">
<img
:src="image.url"
:alt="image.alt"
loading="lazy"
/>
<div
class="swiper-lazy-preloader swiper-lazy-preloader-white"
></div>
</swiper-slide>
</swiper>
</template>
<script>
import {Swiper, SwiperSlide} from 'swiper/vue';
import 'swiper/css';
import 'swiper/css/pagination';
import {Pagination} from 'swiper/modules';
export default {
components: {
Swiper,
SwiperSlide,
},
props: {
images: {
type: Array,
default: () => [],
}
},
setup() {
return {
pagination: {
clickable: true,
dynamicBullets: true,
},
modules: [Pagination],
};
},
};
</script>
<style scoped>
.product-swiper {
width: 100%;
height: auto;
}
.swiper-slide {
text-align: center;
}
img {
width: 100%;
display: block;
object-fit: contain;
}
</style>

View File

@@ -3,9 +3,12 @@ import App from './App.vue'
import './style.css' import './style.css'
import { VueTelegramPlugin } from 'vue-tg'; import { VueTelegramPlugin } from 'vue-tg';
import { router } from './router'; import { router } from './router';
import { createPinia } from 'pinia';
const pinia = createPinia();
const app = createApp(App); const app = createApp(App);
app app
.use(pinia)
.use(router) .use(router)
.use(VueTelegramPlugin); .use(VueTelegramPlugin);
app.mount('#app'); app.mount('#app');
@@ -20,6 +23,4 @@ theme.onChange(() => {
}); });
document.documentElement.setAttribute('data-theme', theme.colorScheme.value); document.documentElement.setAttribute('data-theme', theme.colorScheme.value);
tg.ready(); window.Telegram.WebApp.ready();

View File

@@ -3,12 +3,14 @@ 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 "./views/ProductsList.vue";
import Cart from "./views/Cart.vue";
const routes = [ const routes = [
{path: '/', name: 'home', component: Home}, {path: '/', name: 'home', component: Home},
{path: '/product/:id', name: 'product.show', component: Product}, {path: '/product/:id', name: 'product.show', component: Product},
{path: '/categories', name: 'categories', component: CategoriesList}, {path: '/categories', name: 'categories', component: CategoriesList},
{path: '/category/:id', name: 'category.show', component: ProductsList}, {path: '/category/:id', name: 'category.show', component: ProductsList},
{path: '/cart', name: 'cart.show', component: Cart},
]; ];
export const router = createRouter({ export const router = createRouter({

View File

@@ -0,0 +1,27 @@
import {defineStore} from "pinia";
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
}),
actions: {
getProduct(productId) {
return this.items.find(item => parseInt(item.productId) === parseInt(productId)) ?? null;
},
hasProduct(productId) {
return this.getProduct(productId) !== null;
},
addProduct(productId, productName, price, quantity = 1, options = []) {
this.items.push({
productId: productId,
productName: productName,
price: price,
quantity: quantity,
options: options,
});
},
},
});

69
spa/src/views/Cart.vue Normal file
View File

@@ -0,0 +1,69 @@
<template>
<div class="max-w-3xl mx-auto p-4 space-y-6">
<h2 class="text-2xl font-semibold text-gray-900">Корзина</h2>
<div
v-if="cart.items.length"
class="rounded-2xl border border-gray-200 bg-white shadow-md overflow-hidden divide-y"
>
<div
v-for="item in cart.items"
:key="item.productId"
class="p-4 flex items-center justify-between"
>
<div class="flex-1">
<h3 class="text-base font-semibold text-gray-900">{{ item.productName }}</h3>
<p class="text-sm text-gray-500 mt-1">{{ item.price }}</p>
<div class="flex items-center gap-2 mt-3">
<button
class="w-8 h-8 rounded-full bg-gray-100 text-xl text-gray-700 flex items-center justify-center active:scale-90 transition"
@click="decrease(item)"
></button>
<span class="text-sm font-medium w-6 text-center">{{ item.quantity }}</span>
<button
class="w-8 h-8 rounded-full bg-gray-100 text-xl text-gray-700 flex items-center justify-center active:scale-90 transition"
@click="increase(item)"
></button>
</div>
</div>
<button
@click="remove(item)"
class="ml-4 text-sm text-red-500 hover:underline"
>
Удалить
</button>
</div>
</div>
<div
v-else
class="text-center text-gray-500 py-12 border border-dashed border-gray-300 rounded-2xl bg-white"
>
<p class="text-lg">Ваша корзина пуста</p>
</div>
</div>
</template>
<script setup>
import { useCartStore } from '../stores/CartStore.js'
const cart = useCartStore()
function increase(item) {
item.quantity++
}
function decrease(item) {
if (item.quantity > 1) {
item.quantity--
} else {
remove(item)
}
}
function remove(item) {
const index = cart.items.findIndex(i => i.productId === item.productId)
if (index !== -1) cart.items.splice(index, 1)
}
</script>

View File

@@ -44,22 +44,59 @@
</template> </template>
<script setup> <script setup>
import {onMounted, ref} from "vue"; import {computed, onMounted, onUnmounted, ref, watch, watchEffect} from "vue";
import {$fetch} from "ofetch"; import {$fetch} from "ofetch";
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import {useHapticFeedback} from 'vue-tg'; import {useHapticFeedback} from 'vue-tg';
import ProductOptions from "../components/ProductOptions/ProductOptions.vue"; import ProductOptions from "../components/ProductOptions/ProductOptions.vue";
const hapticFeedback = useHapticFeedback(); const hapticFeedback = useHapticFeedback();
import {useCartStore} from "../stores/CartStore.js";
const router = useRouter() const route = useRoute();
const route = useRoute() const router = useRouter();
const productId = route.params.id const productId = computed(() => route.params.id);
const product = ref([]); const product = ref({});
const cart = useCartStore();
const buttonText = computed(() => {
const item = cart.items.find(i => i.productId === productId.value);
return item && item.quantity > 0
? `В корзине: ${item.quantity} · Перейти`
: 'Добавить в корзину'
});
const isInCartNow = computed(() => {
const item = cart.items.find(i => i.productId === productId.value)
return item && item.quantity > 0
})
watchEffect(() => {
window.Telegram.WebApp.MainButton.setText(buttonText.value);
});
onMounted(async () => { onMounted(async () => {
const {data} = await $fetch(`/index.php?route=extension/tgshop/handle&api_action=product_show&id=${productId}`); const {data} = await $fetch(`/index.php?route=extension/tgshop/handle&api_action=product_show&id=${productId.value}`);
product.value = data; product.value = data;
const tg = window.Telegram.WebApp;
tg.MainButton.show();
tg.MainButton.setText(buttonText.value);
tg.MainButton.hasShineEffect = true;
tg.MainButton.onClick(async () => {
if (cart.hasProduct(productId.value)) {
router.push({name: 'cart.show'});
} else {
cart.addProduct(productId.value, product.value.name, product.value.price, 1, product.value.options);
}
});
});
onUnmounted(() => {
const tg = window.Telegram.WebApp;
tg.MainButton.offClick();
tg.MainButton.hide();
}); });
const carouselRef = ref(); const carouselRef = ref();

View File

@@ -14,11 +14,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"> <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}`">
<div class="carousel carousel-center rounded-box" ref="carouselRef" @scroll.passive="onScroll">
<div v-for="(image, i) in product.images" :key="i" class="carousel-item"> <ProductImageSwiper :images="product.images"/>
<img :src="image.url" :alt="image.alt" loading="lazy"/>
</div>
</div>
<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>
@@ -36,6 +33,7 @@ import {useHapticFeedback} from 'vue-tg';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import ftch from "../utils/ftch.js"; import ftch from "../utils/ftch.js";
import NoProducts from "../components/NoProducts.vue"; import NoProducts from "../components/NoProducts.vue";
import ProductImageSwiper from "../components/ProductImageSwiper.vue";
const hapticFeedback = useHapticFeedback(); const hapticFeedback = useHapticFeedback();
const router = useRouter(); const router = useRouter();