fix: routing/data table #33
9
bo/components.d.ts
vendored
9
bo/components.d.ts
vendored
@@ -12,18 +12,27 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
Cart1: typeof import('./src/components/customer/Cart1.vue')['default']
|
Cart1: typeof import('./src/components/customer/Cart1.vue')['default']
|
||||||
|
CartDetails: typeof import('./src/components/customer/CartDetails.vue')['default']
|
||||||
CategoryMenu: typeof import('./src/components/inner/categoryMenu.vue')['default']
|
CategoryMenu: typeof import('./src/components/inner/categoryMenu.vue')['default']
|
||||||
|
CategoryMenuListing: typeof import('./src/components/inner/categoryMenuListing.vue')['default']
|
||||||
|
copy: typeof import('./src/components/inner/categoryMenu copy.vue')['default']
|
||||||
Cs_PrivacyPolicyView: typeof import('./src/components/terms/cs_PrivacyPolicyView.vue')['default']
|
Cs_PrivacyPolicyView: typeof import('./src/components/terms/cs_PrivacyPolicyView.vue')['default']
|
||||||
Cs_TermsAndConditionsView: typeof import('./src/components/terms/cs_TermsAndConditionsView.vue')['default']
|
Cs_TermsAndConditionsView: typeof import('./src/components/terms/cs_TermsAndConditionsView.vue')['default']
|
||||||
En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default']
|
En_PrivacyPolicyView: typeof import('./src/components/terms/en_PrivacyPolicyView.vue')['default']
|
||||||
En_TermsAndConditionsView: typeof import('./src/components/terms/en_TermsAndConditionsView.vue')['default']
|
En_TermsAndConditionsView: typeof import('./src/components/terms/en_TermsAndConditionsView.vue')['default']
|
||||||
LangSwitch: typeof import('./src/components/inner/langSwitch.vue')['default']
|
LangSwitch: typeof import('./src/components/inner/langSwitch.vue')['default']
|
||||||
|
Page: typeof import('./src/components/customer/Page.vue')['default']
|
||||||
PageAddresses: typeof import('./src/components/customer/PageAddresses.vue')['default']
|
PageAddresses: typeof import('./src/components/customer/PageAddresses.vue')['default']
|
||||||
PageCart: typeof import('./src/components/customer/PageCart.vue')['default']
|
PageCart: typeof import('./src/components/customer/PageCart.vue')['default']
|
||||||
|
PageCarts: typeof import('./src/components/customer/PageCarts.vue')['default']
|
||||||
PageCreateAccount: typeof import('./src/components/customer/PageCreateAccount.vue')['default']
|
PageCreateAccount: typeof import('./src/components/customer/PageCreateAccount.vue')['default']
|
||||||
PageCustomerData: typeof import('./src/components/customer/PageCustomerData.vue')['default']
|
PageCustomerData: typeof import('./src/components/customer/PageCustomerData.vue')['default']
|
||||||
|
PageProduct: typeof import('./src/components/customer/PageProduct.vue')['default']
|
||||||
PageProductCardFull: typeof import('./src/components/customer/PageProductCardFull.vue')['default']
|
PageProductCardFull: typeof import('./src/components/customer/PageProductCardFull.vue')['default']
|
||||||
|
PageProducts: typeof import('./src/components/admin/PageProducts.vue')['default']
|
||||||
PageProductsList: typeof import('./src/components/admin/PageProductsList.vue')['default']
|
PageProductsList: typeof import('./src/components/admin/PageProductsList.vue')['default']
|
||||||
|
PageProfileDetails: typeof import('./src/components/customer/PageProfileDetails.vue')['default']
|
||||||
|
PageProfileDetailsAddInfo: typeof import('./src/components/customer/PageProfileDetailsAddInfo.vue')['default']
|
||||||
Pl_PrivacyPolicyView: typeof import('./src/components/terms/pl_PrivacyPolicyView.vue')['default']
|
Pl_PrivacyPolicyView: typeof import('./src/components/terms/pl_PrivacyPolicyView.vue')['default']
|
||||||
Pl_TermsAndConditionsView: typeof import('./src/components/terms/pl_TermsAndConditionsView.vue')['default']
|
Pl_TermsAndConditionsView: typeof import('./src/components/terms/pl_TermsAndConditionsView.vue')['default']
|
||||||
ProductCustomization: typeof import('./src/components/customer/components/ProductCustomization.vue')['default']
|
ProductCustomization: typeof import('./src/components/customer/components/ProductCustomization.vue')['default']
|
||||||
|
|||||||
@@ -1,14 +1,50 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useFetchJson } from '@/composable/useFetchJson'
|
||||||
import LangSwitch from './inner/langSwitch.vue'
|
import LangSwitch from './inner/langSwitch.vue'
|
||||||
import ThemeSwitch from './inner/themeSwitch.vue'
|
import ThemeSwitch from './inner/themeSwitch.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import type { ApiResponse } from '@/types'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { currentLang } from '@/router/langs'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
let menu = ref()
|
||||||
|
async function getTopMenu() {
|
||||||
|
try {
|
||||||
|
const { items } = await useFetchJson<ApiResponse>('/api/v1/restricted/menu/get-top-menu')
|
||||||
|
menu.value = items
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = computed(() =>
|
||||||
|
transformMenu(menu.value[0].children, currentLang.value?.iso_code)
|
||||||
|
)
|
||||||
|
function transformMenu(items, locale: string | undefined) {
|
||||||
|
return items.map((item) => {
|
||||||
|
const parsedLabel = JSON.parse(item.label)
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: 'i-lucide-house',
|
||||||
|
label: parsedLabel.trans[locale] || parsedLabel.name,
|
||||||
|
to: { name: parsedLabel.name },
|
||||||
|
children: item.children
|
||||||
|
? transformMenu(item.children, locale)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await getTopMenu()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
{{ menuItems }}
|
||||||
|
<!-- fixed top-0 left-0 right-0 z-50 -->
|
||||||
<header
|
<header
|
||||||
class="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-(--black) backdrop-blur-md border-b border-(--border-light) dark:border-(--border-dark)">
|
class=" bg-white/80 dark:bg-(--black) backdrop-blur-md border-b border-(--border-light) dark:border-(--border-dark)">
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between h-14">
|
<div class="flex items-center justify-between h-14">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
@@ -18,28 +54,39 @@ const authStore = useAuthStore()
|
|||||||
</div>
|
</div>
|
||||||
<span class="font-semibold text-gray-900 dark:text-white">TimeTracker</span>
|
<span class="font-semibold text-gray-900 dark:text-white">TimeTracker</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<!-- Right Side Actions -->
|
|
||||||
<RouterLink :to="{ name: 'products-list' }">
|
<UNavigationMenu :items="menuItems" />
|
||||||
|
|
||||||
|
<!-- {{ router }} -->
|
||||||
|
<!-- <RouterLink :to="{ name: 'admin-products' }">
|
||||||
products list
|
products list
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'product-detail' }">
|
<RouterLink :to="{ name: 'admin-product-detail' }">
|
||||||
product detail
|
product detail
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'product-card-full' }">
|
<RouterLink :to="{
|
||||||
ProductCardFull
|
name: 'customer-product', params: {
|
||||||
|
product_id: '51'
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
Product (51)
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'addresses' }">
|
<RouterLink :to="{ name: 'addresses' }">
|
||||||
Addresses
|
Addresses
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'customer-data' }">
|
<RouterLink :to="{ name: 'profile-details' }">
|
||||||
Customer Data
|
Customer Data
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'cart' }">
|
<RouterLink :to="{ name: 'cart' }">
|
||||||
Cart
|
Cart
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'cart1' }">
|
<RouterLink :to="{
|
||||||
Cart1
|
name: 'cart-details', params: {
|
||||||
</RouterLink>
|
cart_id: '1'
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
Cart details (1)
|
||||||
|
</RouterLink> -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Language Switcher -->
|
<!-- Language Switcher -->
|
||||||
<LangSwitch />
|
<LangSwitch />
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UNavigationMenu> -->
|
</UNavigationMenu> -->
|
||||||
{{ filters }}
|
|
||||||
<h1 class="text-2xl font-bold mb-6 text-gray-900 dark:text-white">Products</h1>
|
<h1 class="text-2xl font-bold mb-6 text-gray-900 dark:text-white">Products</h1>
|
||||||
<div v-if="loading" class="text-center py-8">
|
<div v-if="loading" class="text-center py-8">
|
||||||
<span class="text-gray-600 dark:text-gray-400">Loading products...</span>
|
<span class="text-gray-600 dark:text-gray-400">Loading products...</span>
|
||||||
@@ -19,13 +18,16 @@
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="overflow-x-auto">
|
<div v-else class="overflow-x-auto">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<CategoryMenuListing />
|
||||||
<UTable :data="productsList" :columns="columns" class="flex-1">
|
<UTable :data="productsList" :columns="columns" class="flex-1">
|
||||||
<template #expanded="{ row }">
|
<template #expanded="{ row }">
|
||||||
<UTable :data="productsList" :columns="columnsChild" :ui="{
|
<UTable :data="productsList.slice(0, 3)" :columns="columnsChild" :ui="{
|
||||||
thead: 'hidden'
|
thead: 'hidden'
|
||||||
}" />
|
}" />
|
||||||
</template>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
|
</div>
|
||||||
<div class="flex justify-center items-center py-8">
|
<div class="flex justify-center items-center py-8">
|
||||||
<UPagination v-model:page="page" :total="total" :page-size="perPage" />
|
<UPagination v-model:page="page" :total="total" :page-size="perPage" />
|
||||||
</div>
|
</div>
|
||||||
@@ -39,12 +41,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, h, resolveComponent, computed } from 'vue'
|
import { ref, watch, h, resolveComponent, computed } from 'vue'
|
||||||
import { useFetchJson } from '@/composable/useFetchJson'
|
import { useFetchJson } from '@/composable/useFetchJson'
|
||||||
import Default from '@/layouts/default.vue'
|
import Default from '@/layouts/default.vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import type { TableColumn } from '@nuxt/ui'
|
import type { TableColumn } from '@nuxt/ui'
|
||||||
import { log } from 'console'
|
import CategoryMenuListing from '../inner/categoryMenuListing.vue'
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
reference: number
|
reference: number
|
||||||
@@ -98,6 +100,7 @@ const sortField = computed({
|
|||||||
router.push({ query })
|
router.push({ query })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const perPage = ref(15)
|
const perPage = ref(15)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|
||||||
@@ -110,32 +113,64 @@ interface ApiResponse {
|
|||||||
const productsList = ref<Product[]>([])
|
const productsList = ref<Product[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const filters = ref<Record<string, string>>({})
|
const filters = computed<Record<string, string>>({
|
||||||
|
get: () => {
|
||||||
|
const q = { ...route.query }
|
||||||
|
delete q.page
|
||||||
|
delete q.sort
|
||||||
|
delete q.direction
|
||||||
|
return q as Record<string, string>
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
const baseQuery = { ...route.query }
|
||||||
|
|
||||||
|
Object.keys(baseQuery).forEach(key => {
|
||||||
|
if (!['page', 'sort', 'direction'].includes(key)) {
|
||||||
|
delete baseQuery[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
query: {
|
||||||
|
...baseQuery,
|
||||||
|
...val,
|
||||||
|
page: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function debounce(fn: Function, delay = 400) {
|
||||||
|
let t: any
|
||||||
|
return (...args: any[]) => {
|
||||||
|
clearTimeout(t)
|
||||||
|
t = setTimeout(() => fn(...args), delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFilter = debounce((columnId: string, val: string) => {
|
||||||
|
const newFilters = { ...filters.value }
|
||||||
|
|
||||||
|
if (val) newFilters[columnId] = val
|
||||||
|
else delete newFilters[columnId]
|
||||||
|
|
||||||
|
filters.value = newFilters
|
||||||
|
}, 400)
|
||||||
|
|
||||||
async function fetchProductList() {
|
async function fetchProductList() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
const [sort, direction] = sortField.value
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
Object.entries(route.query).forEach(([key, value]) => {
|
||||||
p: String(page.value),
|
if (value) params.append(key, String(value))
|
||||||
elems: String(perPage.value)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (sort && direction) {
|
const url = `/api/v1/restricted/list-products/get-listing?${params}`
|
||||||
params.append('sort', `${sort},${direction}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.entries(filters.value).forEach(([key, value]) => {
|
|
||||||
if (value) params.append(key, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = `/api/v1/restricted/list-products/get-listing?${params.toString()}`
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await useFetchJson<ApiResponse>(url)
|
const response = await useFetchJson<ApiResponse>(url)
|
||||||
|
|
||||||
productsList.value = response.items || []
|
productsList.value = response.items || []
|
||||||
total.value = response.count || 0
|
total.value = response.count || 0
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -207,13 +242,9 @@ const columns: TableColumn<Payment>[] = [
|
|||||||
|
|
||||||
h(UInput, {
|
h(UInput, {
|
||||||
placeholder: 'Search...',
|
placeholder: 'Search...',
|
||||||
modelValue: column.getFilterValue() ?? '',
|
modelValue: filters.value[column.id] ?? '',
|
||||||
'onUpdate:modelValue': (val: string) => {
|
'onUpdate:modelValue': (val: string) => {
|
||||||
if (val) {
|
updateFilter(column.id, val)
|
||||||
filters.value[column.id] = val
|
|
||||||
} else {
|
|
||||||
delete filters.value[column.id]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
size: 'xs'
|
size: 'xs'
|
||||||
})
|
})
|
||||||
@@ -250,13 +281,9 @@ const columns: TableColumn<Payment>[] = [
|
|||||||
|
|
||||||
h(UInput, {
|
h(UInput, {
|
||||||
placeholder: 'Search...',
|
placeholder: 'Search...',
|
||||||
modelValue: column.getFilterValue() ?? '',
|
modelValue: filters.value[column.id] ?? '',
|
||||||
'onUpdate:modelValue': (val: string) => {
|
'onUpdate:modelValue': (val: string) => {
|
||||||
if (val) {
|
updateFilter(column.id, val)
|
||||||
filters.value[column.id] = val
|
|
||||||
} else {
|
|
||||||
delete filters.value[column.id]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
size: 'xs'
|
size: 'xs'
|
||||||
})
|
})
|
||||||
@@ -393,7 +420,11 @@ const columnsChild: TableColumn<Payment>[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
watch([page, sortField, filters.value], () => {
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
() => {
|
||||||
fetchProductList()
|
fetchProductList()
|
||||||
}, { immediate: true })
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
<component :is="Default || 'div'">
|
<component :is="Default || 'div'">
|
||||||
<div class="container my-10 mx-auto ">
|
<div class="container my-10 mx-auto ">
|
||||||
|
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { useProductStore, type Product } from '@/stores/product'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import type { TableColumn } from '@nuxt/ui'
|
|
||||||
import { h } from 'vue'
|
|
||||||
import Default from '@/layouts/default.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const productStore = useProductStore()
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const searchName = ref('')
|
|
||||||
const searchCode = ref('')
|
|
||||||
const priceFromFilter = ref<number | null>(null)
|
|
||||||
const priceToFilter = ref<number | null>(null)
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = 5
|
|
||||||
|
|
||||||
// Fetch products on mount
|
|
||||||
// onMounted(() => {
|
|
||||||
// productStore.getProductDescription(langID: , productID.value)
|
|
||||||
// })
|
|
||||||
|
|
||||||
// Filtered products
|
|
||||||
// const filteredProducts = computed(() => {
|
|
||||||
// console.log(productStore.products);
|
|
||||||
|
|
||||||
// return productStore.products.filter(product => {
|
|
||||||
// const matchesName = product.name.toLowerCase().includes(searchName.value.toLowerCase())
|
|
||||||
// const matchesCode = product.code.toLowerCase().includes(searchCode.value.toLowerCase())
|
|
||||||
// const matchesPriceFrom = priceFromFilter.value === null || product.priceFrom >= priceFromFilter.value
|
|
||||||
// const matchesPriceTo = priceToFilter.value === null || product.priceTo <= priceToFilter.value
|
|
||||||
|
|
||||||
// return matchesName && matchesCode && matchesPriceFrom && matchesPriceTo
|
|
||||||
// })
|
|
||||||
// })
|
|
||||||
|
|
||||||
// const totalItems = computed(() => filteredProducts.value.length)
|
|
||||||
|
|
||||||
// const paginatedProducts = computed(() => {
|
|
||||||
// const start = (page.value - 1) * pageSize
|
|
||||||
// const end = start + pageSize
|
|
||||||
// return filteredProducts.value.slice(start, end)
|
|
||||||
// })
|
|
||||||
|
|
||||||
// Reset page when filters change
|
|
||||||
function resetPage() {
|
|
||||||
page.value = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to product detail
|
|
||||||
function goToProduct(product: Product) {
|
|
||||||
router.push({ name: 'product-detail', params: { id: product.id } })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Table columns
|
|
||||||
const columns = computed<TableColumn<Product>[]>(() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'image',
|
|
||||||
header: () => h('div', { class: 'text-center' }, t('products.image')),
|
|
||||||
cell: ({ row }) => h('img', {
|
|
||||||
src: row.getValue('image'),
|
|
||||||
alt: 'Product',
|
|
||||||
class: 'w-12 h-12 object-cover rounded'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
header: t('products.product_name'),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const product = row.original
|
|
||||||
return h('button', {
|
|
||||||
class: 'text-primary hover:underline font-medium text-left',
|
|
||||||
onClick: (e: Event) => { e.stopPropagation(); goToProduct(product) }
|
|
||||||
}, product.name)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'code',
|
|
||||||
header: t('products.product_code'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'description',
|
|
||||||
header: t('products.description'),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const desc = row.getValue('description') as string
|
|
||||||
return h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, desc?.substring(0, 50) + (desc && desc.length > 50 ? '...' : ''))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'inStock',
|
|
||||||
header: t('products.in_stock'),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const inStock = row.getValue('inStock')
|
|
||||||
return h('span', {
|
|
||||||
class: inStock ? 'text-green-600 font-medium' : 'text-red-600 font-medium'
|
|
||||||
}, inStock ? t('products.yes') : t('products.no'))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'price',
|
|
||||||
header: t('products.price'),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const priceFromVal = row.original.priceFrom
|
|
||||||
const priceToVal = row.original.priceTo
|
|
||||||
return `${priceFromVal} - ${priceToVal}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'count',
|
|
||||||
header: t('products.count'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: '',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const product = row.original
|
|
||||||
return h('div', { class: 'flex gap-2' }, [
|
|
||||||
h('button', {
|
|
||||||
class: 'px-3 py-1.5 text-sm font-medium bg-primary text-white rounded-lg hover:bg-blue-600 transition-colors',
|
|
||||||
onClick: (e: Event) => { e.stopPropagation(); addToCart(product) }
|
|
||||||
}, t('products.add_to_cart')),
|
|
||||||
h('button', {
|
|
||||||
class: 'px-3 py-1.5 text-sm font-medium bg-gray-200 dark:bg-gray-700 text-black dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors',
|
|
||||||
onClick: (e: Event) => { e.stopPropagation(); incrementCount(product) }
|
|
||||||
}, '+')
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
function addToCart(product: Product) {
|
|
||||||
console.log('Add to cart:', product)
|
|
||||||
}
|
|
||||||
|
|
||||||
function incrementCount(product: Product) {
|
|
||||||
product.count++
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearFilters() {
|
|
||||||
searchName.value = ''
|
|
||||||
searchCode.value = ''
|
|
||||||
priceFromFilter.value = null
|
|
||||||
priceToFilter.value = null
|
|
||||||
resetPage()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<component :is="Default || 'div'">
|
|
||||||
<div class="container">
|
|
||||||
<div class="p-6 bg-white dark:bg-(--black) min-h-screen font-sans">
|
|
||||||
<div>
|
|
||||||
<!-- v-html="productStore.products" -->
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-bold mb-6 text-black dark:text-white">{{ t('products.title') }}</h1>
|
|
||||||
|
|
||||||
<div v-if="!authStore.isAuthenticated" class="mb-4 p-3 bg-yellow-100 text-yellow-700 rounded">
|
|
||||||
{{ t('products.login_to_view') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="productStore.loading" class="mb-4 p-3 bg-blue-100 text-blue-700 rounded">
|
|
||||||
{{ t('products.loading') }}...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
|
||||||
<div v-if="productStore.error" class="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
|
||||||
{{ productStore.error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="authStore.isAuthenticated && !productStore.loading && !productStore.error" class="space-y-4">
|
|
||||||
<!-- Filter Block -->
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap gap-4 mb-4 p-4 border border-(--border-light) dark:border-(--border-dark) rounded bg-gray-50 dark:bg-gray-800">
|
|
||||||
<div class="flex flex-col min-w-[180px]">
|
|
||||||
<label class="mb-1 text-sm font-medium text-black dark:text-white">{{ t('products.search_by_name')
|
|
||||||
}}</label>
|
|
||||||
<UInput v-model="searchName" :placeholder="t('products.search_name_placeholder')"
|
|
||||||
@update:model-value="resetPage" class="dark:text-white text-black" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col min-w-[180px]">
|
|
||||||
<label class="mb-1 text-sm font-medium text-black dark:text-white">{{ t('products.search_by_code')
|
|
||||||
}}</label>
|
|
||||||
<UInput v-model="searchCode" :placeholder="t('products.search_code_placeholder')"
|
|
||||||
@update:model-value="resetPage" class="dark:text-white text-black" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col min-w-[120px]">
|
|
||||||
<label class="mb-1 text-sm font-medium text-black dark:text-white">{{ t('products.price_from') }}</label>
|
|
||||||
<UInput v-model="priceFromFilter" type="number" :placeholder="t('products.price_from')"
|
|
||||||
@update:model-value="resetPage" class="dark:text-white text-black" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col min-w-[120px]">
|
|
||||||
<label class="mb-1 text-sm font-medium text-black dark:text-white">{{ t('products.price_to') }}</label>
|
|
||||||
<UInput v-model="priceToFilter" type="number" :placeholder="t('products.price_to')"
|
|
||||||
@update:model-value="resetPage" class="dark:text-white text-black" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button @click="clearFilters"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-black dark:text-white bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
|
||||||
{{ t('products.clear_filters') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Products Table -->
|
|
||||||
<!-- <div class="border border-(--border-light) dark:border-(--border-dark) rounded overflow-hidden">
|
|
||||||
<UTable
|
|
||||||
:data="paginatedProducts"
|
|
||||||
:columns="columns"
|
|
||||||
class="dark:text-white! text-dark"
|
|
||||||
/>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<!-- <div v-if="filteredProducts.length === 0" class="text-center py-10 text-gray-500 dark:text-gray-400">
|
|
||||||
{{ t('products.no_products') }}
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<!-- <div v-if="filteredProducts.length > 0" class="pt-4 flex justify-center items-center dark:text-white! text-dark">
|
|
||||||
<UPagination
|
|
||||||
v-model:page="page"
|
|
||||||
:page-count="pageSize"
|
|
||||||
:total="totalItems"
|
|
||||||
/>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<!-- Results count -->
|
|
||||||
<!-- <div v-if="filteredProducts.length > 0" class="text-sm text-gray-600 dark:text-gray-400 text-center">
|
|
||||||
{{ t('products.showing') }} {{ paginatedProducts.length }} {{ t('products.of') }} {{ totalItems }} {{ t('products.products') }}
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
@@ -65,7 +65,7 @@ function handleCancel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleContinueToCheckout() {
|
function handleContinueToCheckout() {
|
||||||
router.push({ name: 'cart' })
|
router.push({ name: 'carts' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(itemId: number) {
|
function removeItem(itemId: number) {
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
<template>
|
|
||||||
<component :is="Default || 'div'">
|
|
||||||
<div class="container mx-auto mt-20 flex flex-col gap-5 md:gap-10">
|
|
||||||
<h1 class="text-2xl font-bold text-black dark:text-white">{{ t('Shopping Cart') }}</h1>
|
|
||||||
<div class="flex flex-col lg:flex-row gap-5 md:gap-10">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div
|
|
||||||
class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) overflow-hidden">
|
|
||||||
<h2
|
|
||||||
class="text-lg font-semibold text-black dark:text-white p-4 border-b border-(--border-light) dark:border-(--border-dark)">
|
|
||||||
{{ t('Selected Products') }}
|
|
||||||
</h2>
|
|
||||||
<div v-if="cartStore.items.length > 0">
|
|
||||||
<div v-for="item in cartStore.items" :key="item.id"
|
|
||||||
class="grid grid-cols-5 items-center p-4 border-b border-(--border-light) dark:border-(--border-dark) w-[100%]">
|
|
||||||
<div
|
|
||||||
class="bg-(--second-light) dark:bg-(--main-dark) rounded flex items-center justify-center overflow-hidden">
|
|
||||||
<img v-if="item.image" :src="item.image" :alt="item.name" class="w-full h-full object-cover" />
|
|
||||||
<UIcon v-else name="mdi:package-variant" class="text-2xl text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<p class="text-black dark:text-white text-sm font-medium">{{ item.name }}</p>
|
|
||||||
<p class="text-black dark:text-white">${{ item.price.toFixed(2) }}</p>
|
|
||||||
<p class="text-black dark:text-white font-medium">${{ (item.price * item.quantity).toFixed(2)
|
|
||||||
}}</p>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-10">
|
|
||||||
<UInputNumber v-model="item.quantity" :min="1"
|
|
||||||
@update:model-value="(val: number) => cartStore.updateQuantity(item.id, val)" />
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<button @click="removeItem(item.id)"
|
|
||||||
class="p-2 text-red-500 bg-red-100 dark:bg-(--main-dark) rounded transition-colors"
|
|
||||||
:title="t('Remove')">
|
|
||||||
<UIcon name="material-symbols:delete" class="text-[20px]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="p-8 text-center">
|
|
||||||
<UIcon name="mdi:cart-outline" class="text-6xl text-gray-300 dark:text-gray-600 mb-4" />
|
|
||||||
<p class="text-gray-500 dark:text-gray-400">{{ t('Your cart is empty') }}</p>
|
|
||||||
<RouterLink :to="{ name: 'product-card-full' }"
|
|
||||||
class="inline-block mt-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark) hover:underline">
|
|
||||||
{{ t('Continue Shopping') }}
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="lg:w-80">
|
|
||||||
<div
|
|
||||||
class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6 sticky top-24">
|
|
||||||
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Order Summary') }}</h2>
|
|
||||||
<div class="space-y-3 border-b border-(--border-light) dark:border-(--border-dark) pb-4 mb-4">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">{{ t('Products total') }}</span>
|
|
||||||
<span class="text-black dark:text-white">${{ cartStore.productsTotal.toFixed(2) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">{{ t('Shipping') }}</span>
|
|
||||||
<span class="text-black dark:text-white">
|
|
||||||
{{ cartStore.shippingCost > 0 ? `$${cartStore.shippingCost.toFixed(2)}` : t('Free') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">{{ t('VAT') }} ({{ (cartStore.vatRate * 100).toFixed(0)
|
|
||||||
}}%)</span>
|
|
||||||
<span class="text-black dark:text-white">${{ cartStore.vatAmount.toFixed(2) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between mb-6">
|
|
||||||
<span class="text-black dark:text-white font-semibold text-lg">{{ t('Total') }}</span>
|
|
||||||
<span class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) font-bold text-lg">${{
|
|
||||||
cartStore.orderTotal.toFixed(2) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<UButton block color="primary" @click="placeOrder" :disabled="!canPlaceOrder"
|
|
||||||
class="bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white hover:bg-(--accent-blue-dark) dark:hover:bg-(--accent-blue-light) disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
{{ t('Place Order') }}
|
|
||||||
</UButton>
|
|
||||||
<UButton block variant="outline" color="neutral" @click="cancelOrder"
|
|
||||||
class="text-black dark:text-white border-(--border-light) dark:border-(--border-dark) hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
||||||
{{ t('Cancel') }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 md:gap-10">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div
|
|
||||||
class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6">
|
|
||||||
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Select Delivery Address') }}</h2>
|
|
||||||
<div class="mb-4">
|
|
||||||
<UInput v-model="addressSearchQuery" type="text" :placeholder="t('Search address')"
|
|
||||||
class="w-full bg-white dark:bg-gray-800 text-black dark:text-white" />
|
|
||||||
</div>
|
|
||||||
<div v-if="addressStore.filteredAddresses.length > 0" class="space-y-3">
|
|
||||||
<label v-for="address in addressStore.filteredAddresses" :key="address.id"
|
|
||||||
class="flex items-start gap-3 p-4 border rounded-lg cursor-pointer transition-colors" :class="cartStore.selectedAddressId === address.id
|
|
||||||
? 'border-(--accent-blue-light) dark:border-(--accent-blue-dark) bg-blue-50 dark:bg-blue-900/20'
|
|
||||||
: 'border-(--border-light) dark:border-(--border-dark) hover:border-gray-400'">
|
|
||||||
<input type="radio" :value="address.id" v-model="selectedAddress"
|
|
||||||
class="mt-1 w-4 h-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-black dark:text-white font-medium">{{ address.street }}</p>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">{{ address.zipCode }}, {{ address.city }}</p>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">{{ address.country }}</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-center py-6">
|
|
||||||
<UIcon name="mdi:map-marker-outline" class="text-4xl text-gray-400 mb-2" />
|
|
||||||
<p class="text-gray-500 dark:text-gray-400">{{ t('No addresses found') }}</p>
|
|
||||||
<RouterLink :to="{ name: 'addresses' }"
|
|
||||||
class="inline-block mt-2 text-(--accent-blue-light) dark:text-(--accent-blue-dark) hover:underline">
|
|
||||||
{{ t('Add Address') }}
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div
|
|
||||||
class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6">
|
|
||||||
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Delivery Method') }}</h2>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<label v-for="method in cartStore.deliveryMethods" :key="method.id"
|
|
||||||
class="flex items-center gap-3 p-4 border rounded-lg cursor-pointer transition-colors" :class="cartStore.selectedDeliveryMethodId === method.id
|
|
||||||
? 'border-(--accent-blue-light) dark:border-(--accent-blue-dark) bg-blue-50 dark:bg-blue-900/20'
|
|
||||||
: 'border-(--border-light) dark:border-(--border-dark) hover:border-gray-400'">
|
|
||||||
<input type="radio" :value="method.id" v-model="selectedDeliveryMethod"
|
|
||||||
class="w-4 h-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-black dark:text-white font-medium">{{ method.name }}</span>
|
|
||||||
<span class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) font-medium">
|
|
||||||
{{ method.price > 0 ? `$${method.price.toFixed(2)}` : t('Free') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ method.description }}</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { useCartStore } from '@/stores/cart'
|
|
||||||
import { useAddressStore } from '@/stores/address'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import Default from '@/layouts/default.vue'
|
|
||||||
const cartStore = useCartStore()
|
|
||||||
const addressStore = useAddressStore()
|
|
||||||
const { t } = useI18n()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const selectedAddress = ref<number | null>(cartStore.selectedAddressId)
|
|
||||||
const selectedDeliveryMethod = ref<number | null>(cartStore.selectedDeliveryMethodId)
|
|
||||||
const addressSearchQuery = ref('')
|
|
||||||
|
|
||||||
watch(addressSearchQuery, (val) => {
|
|
||||||
addressStore.setSearchQuery(val)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(selectedAddress, (newValue) => {
|
|
||||||
cartStore.setSelectedAddress(newValue)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(selectedDeliveryMethod, (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
cartStore.setDeliveryMethod(newValue)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const canPlaceOrder = computed(() => {
|
|
||||||
return cartStore.items.length > 0 &&
|
|
||||||
cartStore.selectedAddressId !== null &&
|
|
||||||
cartStore.selectedDeliveryMethodId !== null
|
|
||||||
})
|
|
||||||
function removeItem(itemId: number) {
|
|
||||||
cartStore.removeItem(itemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeOrder() {
|
|
||||||
if (canPlaceOrder.value) {
|
|
||||||
console.log('Placing order...')
|
|
||||||
alert(t('Order placed successfully!'))
|
|
||||||
cartStore.clearCart()
|
|
||||||
router.push({ name: 'home' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelOrder() {
|
|
||||||
router.back()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
204
bo/src/components/customer/PageCarts.vue
Normal file
204
bo/src/components/customer/PageCarts.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="Default || 'div'">
|
||||||
|
<div class="container mx-auto mt-20 flex flex-col gap-5 md:gap-10">
|
||||||
|
<h1 class="text-2xl font-bold text-black dark:text-white">{{ t('Shopping Cart') }}</h1>
|
||||||
|
<div class="flex flex-col lg:flex-row gap-5 md:gap-10">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div
|
||||||
|
class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) overflow-hidden">
|
||||||
|
<h2
|
||||||
|
class="text-lg font-semibold text-black dark:text-white p-4 border-b border-(--border-light) dark:border-(--border-dark)">
|
||||||
|
{{ t('Selected Products') }}
|
||||||
|
</h2>
|
||||||
|
<div v-if="cartStore.items.length > 0">
|
||||||
|
<div v-for="item in cartStore.items" :key="item.id"
|
||||||
|
class="grid grid-cols-5 items-center p-4 border-b border-(--border-light) dark:border-(--border-dark) w-[100%]">
|
||||||
|
<div
|
||||||
|
class="bg-(--second-light) dark:bg-(--main-dark) rounded flex items-center justify-center overflow-hidden">
|
||||||
|
<img v-if="item.image" :src="item.image" :alt="item.name" class="w-full h-full object-cover" />
|
||||||
|
<UIcon v-else name="mdi:package-variant" class="text-2xl text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p class="text-black dark:text-white text-sm font-medium">{{ item.name }}</p>
|
||||||
|
<p class="text-black dark:text-white">${{ item.price.toFixed(2) }}</p>
|
||||||
|
<p class="text-black dark:text-white font-medium">${{ (item.price * item.quantity).toFixed(2)
|
||||||
|
}}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-10">
|
||||||
|
<UInputNumber v-model="item.quantity" :min="1"
|
||||||
|
@update:model-value="(val: number) => cartStore.updateQuantity(item.id, val)" />
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button @click="removeItem(item.id)"
|
||||||
|
class="p-2 text-red-500 bg-red-100 dark:bg-(--main-dark) rounded transition-colors"
|
||||||
|
:title="t('Remove')">
|
||||||
|
<UIcon name="material-symbols:delete" class="text-[20px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="p-8 text-center">
|
||||||
|
<UIcon name="mdi:cart-outline" class="text-6xl text-gray-300 dark:text-gray-600 mb-4" />
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">{{ t('Your cart is empty') }}</p>
|
||||||
|
<RouterLink :to="{
|
||||||
|
name: 'customer-product', params: {
|
||||||
|
product_id: '51'
|
||||||
|
}
|
||||||
|
}" class="inline-block mt-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark) hover:underline">
|
||||||
|
{{ t('Continue Shopping') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lg:w-80">
|
||||||
|
<div
|
||||||
|
class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6 sticky top-24">
|
||||||
|
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Order Summary') }}</h2>
|
||||||
|
<div class="space-y-3 border-b border-(--border-light) dark:border-(--border-dark) pb-4 mb-4">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ t('Products total') }}</span>
|
||||||
|
<span class="text-black dark:text-white">${{ cartStore.productsTotal.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ t('Shipping') }}</span>
|
||||||
|
<span class="text-black dark:text-white">
|
||||||
|
{{ cartStore.shippingCost > 0 ? `$${cartStore.shippingCost.toFixed(2)}` : t('Free') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ t('VAT') }} ({{ (cartStore.vatRate * 100).toFixed(0)
|
||||||
|
}}%)</span>
|
||||||
|
<span class="text-black dark:text-white">${{ cartStore.vatAmount.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between mb-6">
|
||||||
|
<span class="text-black dark:text-white font-semibold text-lg">{{ t('Total') }}</span>
|
||||||
|
<span class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) font-bold text-lg">${{
|
||||||
|
cartStore.orderTotal.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<UButton block color="primary" @click="placeOrder" :disabled="!canPlaceOrder"
|
||||||
|
class="bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white hover:bg-(--accent-blue-dark) dark:hover:bg-(--accent-blue-light) disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
{{ t('Place Order') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton block variant="outline" color="neutral" @click="cancelOrder"
|
||||||
|
class="text-black dark:text-white border-(--border-light) dark:border-(--border-dark) hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
{{ t('Cancel') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 md:gap-10">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div
|
||||||
|
class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Select Delivery Address') }}</h2>
|
||||||
|
<div class="mb-4">
|
||||||
|
<UInput v-model="addressSearchQuery" type="text" :placeholder="t('Search address')"
|
||||||
|
class="w-full bg-white dark:bg-gray-800 text-black dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div v-if="addressStore.filteredAddresses.length > 0" class="space-y-3">
|
||||||
|
<label v-for="address in addressStore.filteredAddresses" :key="address.id"
|
||||||
|
class="flex items-start gap-3 p-4 border rounded-lg cursor-pointer transition-colors" :class="cartStore.selectedAddressId === address.id
|
||||||
|
? 'border-(--accent-blue-light) dark:border-(--accent-blue-dark) bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-(--border-light) dark:border-(--border-dark) hover:border-gray-400'">
|
||||||
|
<input type="radio" :value="address.id" v-model="selectedAddress"
|
||||||
|
class="mt-1 w-4 h-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-black dark:text-white font-medium">{{ address.street }}</p>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">{{ address.zipCode }}, {{ address.city }}</p>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">{{ address.country }}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center py-6">
|
||||||
|
<UIcon name="mdi:map-marker-outline" class="text-4xl text-gray-400 mb-2" />
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">{{ t('No addresses found') }}</p>
|
||||||
|
<RouterLink :to="{ name: 'addresses' }"
|
||||||
|
class="inline-block mt-2 text-(--accent-blue-light) dark:text-(--accent-blue-dark) hover:underline">
|
||||||
|
{{ t('Add Address') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div
|
||||||
|
class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-black dark:text-white mb-4">{{ t('Delivery Method') }}</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label v-for="method in cartStore.deliveryMethods" :key="method.id"
|
||||||
|
class="flex items-center gap-3 p-4 border rounded-lg cursor-pointer transition-colors" :class="cartStore.selectedDeliveryMethodId === method.id
|
||||||
|
? 'border-(--accent-blue-light) dark:border-(--accent-blue-dark) bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-(--border-light) dark:border-(--border-dark) hover:border-gray-400'">
|
||||||
|
<input type="radio" :value="method.id" v-model="selectedDeliveryMethod"
|
||||||
|
class="w-4 h-4 text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-black dark:text-white font-medium">{{ method.name }}</span>
|
||||||
|
<span class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) font-medium">
|
||||||
|
{{ method.price > 0 ? `$${method.price.toFixed(2)}` : t('Free') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ method.description }}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useCartStore } from '@/stores/cart'
|
||||||
|
import { useAddressStore } from '@/stores/address'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import Default from '@/layouts/default.vue'
|
||||||
|
const cartStore = useCartStore()
|
||||||
|
const addressStore = useAddressStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const selectedAddress = ref<number | null>(cartStore.selectedAddressId)
|
||||||
|
const selectedDeliveryMethod = ref<number | null>(cartStore.selectedDeliveryMethodId)
|
||||||
|
const addressSearchQuery = ref('')
|
||||||
|
|
||||||
|
watch(addressSearchQuery, (val) => {
|
||||||
|
addressStore.setSearchQuery(val)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedAddress, (newValue) => {
|
||||||
|
cartStore.setSelectedAddress(newValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedDeliveryMethod, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
cartStore.setDeliveryMethod(newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const canPlaceOrder = computed(() => {
|
||||||
|
return cartStore.items.length > 0 &&
|
||||||
|
cartStore.selectedAddressId !== null &&
|
||||||
|
cartStore.selectedDeliveryMethodId !== null
|
||||||
|
})
|
||||||
|
function removeItem(itemId: number) {
|
||||||
|
cartStore.removeItem(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeOrder() {
|
||||||
|
if (canPlaceOrder.value) {
|
||||||
|
console.log('Placing order...')
|
||||||
|
alert(t('Order placed successfully!'))
|
||||||
|
cartStore.clearCart()
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelOrder() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -118,6 +118,6 @@ const companyAddress = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function goToCreateAccount() {
|
function goToCreateAccount() {
|
||||||
router.push({ name: 'create-account' })
|
router.push({ name: 'profile-details-add-info' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -173,9 +173,9 @@ function saveAccount() {
|
|||||||
companyAddress : ''
|
companyAddress : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
router.push({ name: 'customer-data' })
|
router.push({ name: 'profile-details' })
|
||||||
}
|
}
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push({ name: 'customer-data' })
|
router.push({ name: 'profile-details' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -29,12 +29,8 @@ const items = ref<NavigationMenuItem[][]>([
|
|||||||
],
|
],
|
||||||
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UNavigationMenu orientation="vertical" type="single" :items="items" class="p-4">
|
<UNavigationMenu orientation="vertical" type="single" :items="items" class="p-4" />
|
||||||
</UNavigationMenu>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
35
bo/src/components/inner/categoryMenuListing.vue
Normal file
35
bo/src/components/inner/categoryMenuListing.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getMenu } from '@/router/menu'
|
||||||
|
import type { NavigationMenuItem } from '@nuxt/ui';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
let menu = await getMenu() as NavigationMenuItem[]
|
||||||
|
|
||||||
|
const openAll = ref(false)
|
||||||
|
|
||||||
|
function adaptMenu(menu: NavigationMenuItem[]) {
|
||||||
|
for (const item of menu) {
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
adaptMenu(item.children);
|
||||||
|
item.open = openAll.value
|
||||||
|
item.children.unshift({ label: item.label, icon: 'i-lucide-book-open', popover: item.label, to: { name: 'category', params: item.params } })
|
||||||
|
} else {
|
||||||
|
item.to = { name: 'category', params: item.params };
|
||||||
|
item.icon = 'i-lucide-file-text'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu = adaptMenu(menu)
|
||||||
|
|
||||||
|
const items = ref<NavigationMenuItem[][]>([
|
||||||
|
[
|
||||||
|
...menu as NavigationMenuItem[]
|
||||||
|
],
|
||||||
|
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UNavigationMenu orientation="vertical" type="single" :items="items" class="p-4" />
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
{{ locale }}
|
||||||
<USelectMenu v-model="locale" :items="langs" class="w-40 bg-(--main-light) dark:bg-(--black) rounded-md shadow-sm hover:none!"
|
<USelectMenu v-model="locale" :items="langs" class="w-40 bg-(--main-light) dark:bg-(--black) rounded-md shadow-sm hover:none!"
|
||||||
valueKey="iso_code" :searchInput="false">
|
valueKey="iso_code" :searchInput="false">
|
||||||
<template #default="{ modelValue }">
|
<template #default="{ modelValue }">
|
||||||
|
|||||||
@@ -2,17 +2,13 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import { currentLang, langs } from './langs'
|
import { currentLang, langs } from './langs'
|
||||||
import { getSettings } from './settings'
|
import { getSettings } from './settings'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
// import Default from '@/layouts/default.vue'
|
import { getRoutes } from './menu'
|
||||||
// import { getMenu } from './menu'
|
|
||||||
|
|
||||||
|
|
||||||
function isAuthenticated(): boolean {
|
function isAuthenticated(): boolean {
|
||||||
if (typeof document === 'undefined') return false
|
if (typeof document === 'undefined') return false
|
||||||
return document.cookie.split('; ').some((c) => c === 'is_authenticated=1')
|
return document.cookie.split('; ').some((c) => c === 'is_authenticated=1')
|
||||||
}
|
}
|
||||||
await getSettings()
|
await getSettings()
|
||||||
// await getMenu()
|
|
||||||
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.VITE_BASE_URL),
|
history: createWebHistory(import.meta.env.VITE_BASE_URL),
|
||||||
@@ -23,68 +19,41 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/:locale',
|
path: '/:locale',
|
||||||
|
name: 'locale',
|
||||||
children: [
|
children: [
|
||||||
{ path: 'category/:category_id-:link_rewrite', component: () => import('@/views/CategoryView.vue'), name: 'category' },
|
|
||||||
{ path: 'products-datail', component: () => import('@/components/admin/ProductDetailView.vue'), name: 'product-detail' },
|
|
||||||
{ path: '', component: () => import('@/views/RepoChartView.vue'), name: 'home' },
|
|
||||||
{ path: 'products', component: () => import('@/components/admin/ProductsView.vue'), name: 'products' },
|
|
||||||
{ path: 'product-card-full', component: () => import('@/components/customer/PageProductCardFull.vue'), name: 'product-card-full' },
|
|
||||||
{ path: 'addresses', component: () => import('@/components/customer/PageAddresses.vue'), name: 'addresses' },
|
|
||||||
{ path: 'customer-data', component: () => import('@/components/customer/PageCustomerData.vue'), name: 'customer-data' },
|
|
||||||
{ path: 'create-account', component: () => import('@/components/customer/PageCreateAccount.vue'), name: 'create-account' },
|
|
||||||
{ path: 'cart', component: () => import('@/components/customer/PageCart.vue'), name: 'cart' },
|
|
||||||
{ path: 'cart1', component: () => import('@/components/customer/Cart1.vue'), name: 'cart1' },
|
|
||||||
{ path: 'products-list', component: () => import('@/components/admin/PageProductsList.vue'), name: 'products-list' },
|
|
||||||
{ path: 'login', component: () => import('@/views/LoginView.vue'), name: 'login', meta: { guest: true, } },
|
|
||||||
{ path: 'register', component: () => import('@/views/RegisterView.vue'), name: 'register', meta: { guest: true } },
|
|
||||||
{ path: 'password-recovery', component: () => import('@/views/PasswordRecoveryView.vue'), name: 'password-recovery', meta: { guest: true } },
|
|
||||||
{ path: 'reset-password', component: () => import('@/views/ResetPasswordForm.vue'), name: 'reset-password', meta: { guest: true } },
|
|
||||||
{ path: 'verify-email', component: () => import('@/views/VerifyEmailView.vue'), name: 'verify-email', meta: { guest: true } },
|
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
component: () => import('@/views/NotFoundView.vue'),
|
component: () => import('@/views/NotFoundView.vue'),
|
||||||
name: 'not-found',
|
name: 'not-found-child',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
component: () => import('@/views/NotFoundView.vue'),
|
component: () => import('@/views/NotFoundView.vue'),
|
||||||
name: 'not-found',
|
name: 'not-found-root',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// router.beforeEach((to, from, next) => {
|
await getRoutes().then(routes => {
|
||||||
// const locale = to.params.locale as string
|
const modules = import.meta.glob('/src/**/**/*.vue')
|
||||||
// const localeLang = langs.find((x) => x.iso_code == locale)
|
routes.forEach(item => {
|
||||||
|
const component = modules[`/src${item.Component}`]
|
||||||
|
|
||||||
// if (locale && langs.length > 0) {
|
if (!component) {
|
||||||
// const authStore = useAuthStore()
|
console.error('Component not found:', item.Component)
|
||||||
// // console.log(authStore.isAuthenticated, to, from)
|
return
|
||||||
// // if()
|
}
|
||||||
// const validLocale = langs.find((l) => l.lang_code === locale)
|
|
||||||
|
|
||||||
// if (validLocale) {
|
router.addRoute('locale', {
|
||||||
// currentLang.value = localeLang
|
path: item.Path,
|
||||||
// if (!to.meta?.guest && !isAuthenticated()) {
|
component,
|
||||||
// return next({ name: 'login', params: { locale } })
|
name: item.Name,
|
||||||
// }
|
meta: item.Meta ? JSON.parse(item.Meta) : {}
|
||||||
|
})
|
||||||
// // return next()
|
})
|
||||||
// return next()
|
})
|
||||||
// } else if (locale) {
|
|
||||||
// return next(`/${currentLang.value?.iso_code}${to.path.replace(`/${locale}`, '') || '/'}`)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (!locale && to.path !== '/') {
|
|
||||||
// return next(`/${currentLang.value?.iso_code}${to.path}`)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// next()
|
|
||||||
// })
|
|
||||||
|
|
||||||
router.beforeEach((to, from) => {
|
router.beforeEach((to, from) => {
|
||||||
const locale = to.params.locale as string
|
const locale = to.params.locale as string
|
||||||
|
|||||||
6
bo/src/types/index.d.ts
vendored
6
bo/src/types/index.d.ts
vendored
@@ -1,2 +1,8 @@
|
|||||||
export * from '@types/lang'
|
export * from '@types/lang'
|
||||||
export * from '@types/response'
|
export * from '@types/response'
|
||||||
|
|
||||||
|
export interface ApiResponse {
|
||||||
|
message: string
|
||||||
|
items: Product[]
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user