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

View File

@@ -1,21 +1,21 @@
<template>
<div class="app-container">
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
<router-view />
<router-view/>
</div>
</template>
<script setup>
import {onMounted, ref, watch} from "vue";
import { useWebAppViewport, useBackButton } from 'vue-tg';
import { useMiniApp, FullscreenViewport } from 'vue-tg';
import {useWebAppViewport, useBackButton} from 'vue-tg';
import {useMiniApp, FullscreenViewport} from 'vue-tg';
import {useRoute, useRouter} from "vue-router";
const tg = useMiniApp();
const platform = ref();
platform.value = tg.platform;
const { disableVerticalSwipes } = useWebAppViewport();
const {disableVerticalSwipes} = useWebAppViewport();
disableVerticalSwipes();
const router = useRouter();
@@ -32,6 +32,6 @@ watch(
backButton.onClick?.(() => router.back());
}
},
{ immediate: true, deep: true }
{immediate: true, deep: true}
);
</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 { VueTelegramPlugin } from 'vue-tg';
import { router } from './router';
import { createPinia } from 'pinia';
const pinia = createPinia();
const app = createApp(App);
app
.use(pinia)
.use(router)
.use(VueTelegramPlugin);
app.mount('#app');
@@ -20,6 +23,4 @@ theme.onChange(() => {
});
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 CategoriesList from "./views/CategoriesList.vue";
import ProductsList from "./views/ProductsList.vue";
import Cart from "./views/Cart.vue";
const routes = [
{path: '/', name: 'home', component: Home},
{path: '/product/:id', name: 'product.show', component: Product},
{path: '/categories', name: 'categories', component: CategoriesList},
{path: '/category/:id', name: 'category.show', component: ProductsList},
{path: '/cart', name: 'cart.show', component: Cart},
];
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>
<script setup>
import {onMounted, ref} from "vue";
import {computed, onMounted, onUnmounted, ref, watch, watchEffect} from "vue";
import {$fetch} from "ofetch";
import { useRoute } from 'vue-router'
import { useRouter } from 'vue-router'
import {useHapticFeedback} from 'vue-tg';
import ProductOptions from "../components/ProductOptions/ProductOptions.vue";
const hapticFeedback = useHapticFeedback();
import {useCartStore} from "../stores/CartStore.js";
const router = useRouter()
const route = useRoute()
const productId = route.params.id
const product = ref([]);
const route = useRoute();
const router = useRouter();
const productId = computed(() => route.params.id);
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 () => {
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;
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();

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">
<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">
<img :src="image.url" :alt="image.alt" loading="lazy"/>
</div>
</div>
<ProductImageSwiper :images="product.images"/>
<h3 class="mt-4 text-sm">{{ product.name }}</h3>
<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 ftch from "../utils/ftch.js";
import NoProducts from "../components/NoProducts.vue";
import ProductImageSwiper from "../components/ProductImageSwiper.vue";
const hapticFeedback = useHapticFeedback();
const router = useRouter();