Merge remote-tracking branch 'origin/translate' into front-styles
This commit is contained in:
11
bo/components.d.ts
vendored
11
bo/components.d.ts
vendored
@@ -11,12 +11,16 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
ButtonGoToProfile: typeof import('./src/components/customer-management/ButtonGoToProfile.vue')['default']
|
||||||
CartDetails: typeof import('./src/components/customer/CartDetails.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']
|
||||||
|
copy: typeof import('./src/components/admin/ProductDetailView copy.vue')['default']
|
||||||
|
CountryCurrencySwitch: typeof import('./src/components/inner/CountryCurrencySwitch.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']
|
||||||
|
FavoriteProducts: typeof import('./src/components/admin/FavoriteProducts.vue')['default']
|
||||||
LangSwitch: typeof import('./src/components/inner/LangSwitch.vue')['default']
|
LangSwitch: typeof import('./src/components/inner/LangSwitch.vue')['default']
|
||||||
PageAddresses: typeof import('./src/components/customer/PageAddresses.vue')['default']
|
PageAddresses: typeof import('./src/components/customer/PageAddresses.vue')['default']
|
||||||
PageCarts: typeof import('./src/components/customer/PageCarts.vue')['default']
|
PageCarts: typeof import('./src/components/customer/PageCarts.vue')['default']
|
||||||
@@ -33,8 +37,10 @@ declare module 'vue' {
|
|||||||
'ProductDetailView copy': typeof import('./src/components/admin/ProductDetailView copy.vue')['default']
|
'ProductDetailView copy': typeof import('./src/components/admin/ProductDetailView copy.vue')['default']
|
||||||
ProductEditor: typeof import('./src/components/inner/ProductEditor.vue')['default']
|
ProductEditor: typeof import('./src/components/inner/ProductEditor.vue')['default']
|
||||||
ProductVariants: typeof import('./src/components/customer/components/ProductVariants.vue')['default']
|
ProductVariants: typeof import('./src/components/customer/components/ProductVariants.vue')['default']
|
||||||
|
Profile: typeof import('./src/components/customer-management/Profile.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
StorageFileBrowser: typeof import('./src/components/customer/StorageFileBrowser.vue')['default']
|
||||||
ThemeSwitch: typeof import('./src/components/inner/ThemeSwitch.vue')['default']
|
ThemeSwitch: typeof import('./src/components/inner/ThemeSwitch.vue')['default']
|
||||||
TopBar: typeof import('./src/components/TopBar.vue')['default']
|
TopBar: typeof import('./src/components/TopBar.vue')['default']
|
||||||
TopBarLogin: typeof import('./src/components/TopBarLogin.vue')['default']
|
TopBarLogin: typeof import('./src/components/TopBarLogin.vue')['default']
|
||||||
@@ -57,9 +63,12 @@ declare module 'vue' {
|
|||||||
UPagination: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Pagination.vue')['default']
|
UPagination: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Pagination.vue')['default']
|
||||||
USelect: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Select.vue')['default']
|
USelect: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Select.vue')['default']
|
||||||
USelectMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
|
USelectMenu: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
|
||||||
|
UsersList: typeof import('./src/components/admin/UsersList.vue')['default']
|
||||||
|
UsersSearch: typeof import('./src/components/admin/UsersSearch.vue')['default']
|
||||||
USidebar: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Sidebar.vue')['default']
|
USidebar: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Sidebar.vue')['default']
|
||||||
UTable: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default']
|
UTable: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default']
|
||||||
UTabs: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
|
UTabs: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
|
||||||
UTextarea: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Textarea.vue')['default']
|
UTextarea: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Textarea.vue')['default']
|
||||||
|
UTree: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Tree.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TooltipProvider } from 'reka-ui'
|
import { TooltipProvider } from 'reka-ui'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import { useAuthStore } from './stores/customer/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -12,35 +15,3 @@ import { RouterView } from 'vue-router'
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<!-- <template>
|
|
||||||
<component :is="layoutComponent">
|
|
||||||
<Suspense>
|
|
||||||
<RouterView />
|
|
||||||
</Suspense>
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
import DefaultLayout from '@/layouts/default.vue'
|
|
||||||
import EmptyLayout from '@/layouts/empty.vue'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const layouts = {
|
|
||||||
default: DefaultLayout,
|
|
||||||
auth: EmptyLayout
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(route.fullPath)
|
|
||||||
console.log(route.name)
|
|
||||||
console.log(route.matched)
|
|
||||||
|
|
||||||
const layoutComponent = computed(() => {
|
|
||||||
console.log(route.meta);
|
|
||||||
|
|
||||||
return layouts[route.meta.layout as keyof typeof layouts] || DefaultLayout
|
|
||||||
})
|
|
||||||
</script> -->
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const uiOptions: NuxtUIOptions = {
|
|||||||
table: {
|
table: {
|
||||||
slots: {
|
slots: {
|
||||||
base: 'border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! bg-(--second-light) dark:bg-(--main-dark)',
|
base: 'border! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! bg-(--second-light) dark:bg-(--main-dark)',
|
||||||
tr: 'border-b! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! text-(--black)! dark:text-white!',
|
// tr: 'border-b! border-(--border-light)! dark:border-(--border-dark)! outline-0! ring-0! text-(--black)! dark:text-white!',
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
import { useFetchJson } from '@/composable/useFetchJson'
|
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/customer/auth'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { currentLang } from '@/router/langs'
|
import { currentLang } from '@/router/langs'
|
||||||
import type { LabelTrans, TopMenuItem } from '@/types'
|
import type { LabelTrans, TopMenuItem } from '@/types'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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/customer/auth'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
12
bo/src/components/admin/FavoriteProducts.vue
Normal file
12
bo/src/components/admin/FavoriteProducts.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="Default || 'div'">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Default from '@/layouts/default.vue';
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -141,7 +141,7 @@ async function fetchProductList() {
|
|||||||
if (route.params.category_id)
|
if (route.params.category_id)
|
||||||
params.append('category_id', String(route.params.category_id))
|
params.append('category_id', String(route.params.category_id))
|
||||||
|
|
||||||
const url = `/api/v1/restricted/list/list-products?elems=${perPage.value}&${params.toString()}`
|
const url = `/api/v1/restricted/product/list?elems=${perPage.value}&${params.toString()}`
|
||||||
try {
|
try {
|
||||||
const response = await useFetchJson<ApiResponse>(url)
|
const response = await useFetchJson<ApiResponse>(url)
|
||||||
productsList.value = response.items || []
|
productsList.value = response.items || []
|
||||||
@@ -161,7 +161,7 @@ function goToProduct(productId: number, linkRewrite: string) {
|
|||||||
}
|
}
|
||||||
localStorage.setItem('back_from_product', JSON.stringify(path))
|
localStorage.setItem('back_from_product', JSON.stringify(path))
|
||||||
router.push({
|
router.push({
|
||||||
name: 'customer-product-details',
|
name: 'admin-product-details',
|
||||||
params: { product_id: productId, link_rewrite: linkRewrite }
|
params: { product_id: productId, link_rewrite: linkRewrite }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ import { useEditable } from '@/composable/useConteditable';
|
|||||||
import Default from '@/layouts/default.vue';
|
import Default from '@/layouts/default.vue';
|
||||||
import { langs } from '@/router/langs';
|
import { langs } from '@/router/langs';
|
||||||
import { useProductStore } from '@/stores/product';
|
import { useProductStore } from '@/stores/product';
|
||||||
import { useSettingsStore } from '@/stores/settings';
|
import { useSettingsStore } from '@/stores/admin/settings';
|
||||||
import type { EditorToolbarItem } from '@nuxt/ui';
|
import type { EditorToolbarItem } from '@nuxt/ui';
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|||||||
@@ -90,6 +90,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="" v-if="isTranslations">
|
||||||
|
<p>Link rewrite:</p>
|
||||||
|
<UTextarea :rows="1" v-model="productStore.productDescription.link_rewrite" autoresize :ui="{
|
||||||
|
root: 'w-full',
|
||||||
|
base: 'bg-inherit!',
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="" v-if="isTranslations">
|
<div class="" v-if="isTranslations">
|
||||||
<p>Link rewrite:</p>
|
<p>Link rewrite:</p>
|
||||||
<UTextarea :rows="1" v-model="productStore.productDescription.link_rewrite" autoresize :ui="{
|
<UTextarea :rows="1" v-model="productStore.productDescription.link_rewrite" autoresize :ui="{
|
||||||
@@ -152,8 +160,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Default from '@/layouts/default.vue';
|
import Default from '@/layouts/default.vue';
|
||||||
import { langs } from '@/router/langs';
|
import { langs } from '@/router/langs';
|
||||||
import { useProductStore } from '@/stores/product';
|
import { useProductStore } from '@/stores/admin/product';
|
||||||
import { useSettingsStore } from '@/stores/settings';
|
import { useSettingsStore } from '@/stores/admin/settings';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import ProductEditor from '../inner/ProductEditor.vue';
|
import ProductEditor from '../inner/ProductEditor.vue';
|
||||||
|
|||||||
211
bo/src/components/admin/UsersList.vue
Normal file
211
bo/src/components/admin/UsersList.vue
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="Default || 'div'">
|
||||||
|
<div class="flex flex-col md:flex-row gap-10">
|
||||||
|
<div class="w-full flex flex-col items-center gap-4">
|
||||||
|
<UTable :data="usersList" :columns="columns" class="flex-1 w-full"
|
||||||
|
:ui="{ root: 'max-w-100wv overflow-auto!' }" />
|
||||||
|
<UPagination v-model:page="page" :total="total" :items-per-page="perPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Default from '@/layouts/default.vue'
|
||||||
|
import { ref, computed, watch, resolveComponent, h } from 'vue'
|
||||||
|
import { useFetchJson } from '@/composable/useFetchJson'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import CategoryMenu from '../inner/CategoryMenu.vue'
|
||||||
|
import type { TableColumn } from '@nuxt/ui'
|
||||||
|
import type { Customer } from '@/types/user'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const perPage = ref(15)
|
||||||
|
const page = computed({
|
||||||
|
get: () => Number(route.query.p) || 1,
|
||||||
|
set: (val: number) => {
|
||||||
|
router.push({ query: { ...route.query, p: val } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortField = computed({
|
||||||
|
get: () => [
|
||||||
|
route.query.sort as string | undefined,
|
||||||
|
route.query.direction as 'asc' | 'desc' | undefined
|
||||||
|
],
|
||||||
|
set: ([sort]: [string, 'asc' | 'desc']) => {
|
||||||
|
const currentSort = route.query.sort as string | undefined
|
||||||
|
const currentDirection = route.query.direction as 'asc' | 'desc' | undefined
|
||||||
|
const query = { ...route.query }
|
||||||
|
|
||||||
|
if (currentSort === sort) {
|
||||||
|
if (currentDirection === 'asc') query.direction = 'desc'
|
||||||
|
else if (currentDirection === 'desc') {
|
||||||
|
delete query.sort
|
||||||
|
delete query.direction
|
||||||
|
} else {
|
||||||
|
query.direction = 'asc'
|
||||||
|
query.sort = sort
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
query.sort = sort
|
||||||
|
query.direction = 'asc'
|
||||||
|
}
|
||||||
|
router.push({ query })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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(k => {
|
||||||
|
if (!['page', 'sort', 'direction'].includes(k)) delete baseQuery[k]
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
|
||||||
|
const usersList = ref<Customer[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchUsersList() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const params = new URLSearchParams(route.query as any).toString()
|
||||||
|
const url = `/api/v1/restricted/customer/list?elems=${perPage.value}&${params}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await useFetchJson<{ items: { items: Customer[] }; count: number }>(url)
|
||||||
|
usersList.value = res.items?.items || []
|
||||||
|
total.value = res.count || 0
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load users'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcon(name: string) {
|
||||||
|
if (sortField.value[0] === name) {
|
||||||
|
if (sortField.value[1] === 'asc') return 'i-lucide-arrow-up-narrow-wide'
|
||||||
|
if (sortField.value[1] === 'desc') return 'i-lucide-arrow-down-wide-narrow'
|
||||||
|
}
|
||||||
|
return 'i-lucide-arrow-up-down'
|
||||||
|
}
|
||||||
|
|
||||||
|
const UInput = resolveComponent('UInput')
|
||||||
|
const UIcon = resolveComponent('UIcon')
|
||||||
|
const UButton = resolveComponent('UButton')
|
||||||
|
|
||||||
|
const columns: TableColumn<Customer>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'user_id',
|
||||||
|
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
|
||||||
|
h('div', {
|
||||||
|
class: 'flex items-center gap-2 cursor-pointer',
|
||||||
|
onClick: () => (sortField.value = ['user_id', 'asc'])
|
||||||
|
}, [h('span', 'Client ID'), h(UIcon, { name: getIcon('user_id') })]),
|
||||||
|
h(UInput, {
|
||||||
|
placeholder: 'Search...',
|
||||||
|
modelValue: filters.value[column.id] ?? '',
|
||||||
|
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
|
||||||
|
size: 'xs'
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
cell: ({ row }) => `#${row.getValue('user_id')}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
|
||||||
|
h('div', {
|
||||||
|
class: 'flex items-center gap-2 cursor-pointer',
|
||||||
|
onClick: () => (sortField.value = ['last_name, first_name', 'asc'])
|
||||||
|
}, [h('span', 'Name/Surname'), h(UIcon, { name: getIcon('name') })]),
|
||||||
|
h(UInput, {
|
||||||
|
placeholder: 'Search...',
|
||||||
|
modelValue: filters.value[column.id] ?? '',
|
||||||
|
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
|
||||||
|
size: 'xs'
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
cell: ({ row }) => `${row.original.first_name} ${row.original.last_name}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'email',
|
||||||
|
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
|
||||||
|
h('div', {
|
||||||
|
class: 'flex items-center gap-2 cursor-pointer',
|
||||||
|
onClick: () => (sortField.value = ['email', 'asc'])
|
||||||
|
}, [h('span', 'Email'), h(UIcon, { name: getIcon('email') })]),
|
||||||
|
h(UInput, {
|
||||||
|
placeholder: 'Search...',
|
||||||
|
modelValue: filters.value[column.id] ?? '',
|
||||||
|
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
|
||||||
|
size: 'xs'
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
cell: ({ row }) => row.getValue('email')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'count',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return h(UButton, {
|
||||||
|
// onClick: () => {
|
||||||
|
// goToProduct(row.original.product_id, row.original.link_rewrite)
|
||||||
|
// },
|
||||||
|
class: 'cursor-pointer',
|
||||||
|
color: 'info',
|
||||||
|
variant: 'soft'
|
||||||
|
}, () => 'Manage')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'profile',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const userId = row.original.user_id
|
||||||
|
return h(UButton, {
|
||||||
|
color: 'info',
|
||||||
|
size: 'sm',
|
||||||
|
variant: 'soft',
|
||||||
|
onClick: () => {
|
||||||
|
router.push({
|
||||||
|
name: 'customer-management-profile',
|
||||||
|
params: { user_id: userId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, () => 'Go to profile')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
watch(() => route.query, fetchUsersList, { immediate: true })
|
||||||
|
</script>
|
||||||
245
bo/src/components/admin/UsersSearch.vue
Normal file
245
bo/src/components/admin/UsersSearch.vue
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="Default">
|
||||||
|
<div class="pt-70! flex flex-col items-center justify-center bg-gray-50 dark:bg-(--main-dark)">
|
||||||
|
<h1 class="text-6xl font-bold text-black dark:text-white mb-14">Search Users</h1>
|
||||||
|
|
||||||
|
<div class="w-full max-w-4xl">
|
||||||
|
<UInput icon="i-lucide-search" type="text" placeholder="Type user name or ID..." v-model="searchQuery"
|
||||||
|
class="w-full!" :ui="{ base: 'py-4! rounded-full!' }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loading">Loading...</p>
|
||||||
|
<p v-else-if="error" class="text-red-600">{{ error }}</p>
|
||||||
|
<div v-else-if="clients.length" class="w-full max-w-4xl mt-7">
|
||||||
|
<UTable :columns="columns" :data="clients" :ui="{ root: 'w-full!' }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="searchQuery.length" class="pt-4 text-gray-700">
|
||||||
|
No users found with that name or ID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, resolveComponent, h } from 'vue'
|
||||||
|
import Default from '@/layouts/default.vue';
|
||||||
|
import type { TableColumn } from '@nuxt/ui';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useFetchJson } from '@/composable/useFetchJson';
|
||||||
|
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
user_id: number;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const clients = ref<Customer[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const limit = ref(10);
|
||||||
|
const page = ref(1);
|
||||||
|
|
||||||
|
|
||||||
|
function debounce(fn: Function, delay = 400) {
|
||||||
|
let t: any;
|
||||||
|
return (...args: any[]) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(() => fn(...args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const searchClients = async () => {
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
clients.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await useFetchJson(
|
||||||
|
`/api/v1/restricted/customer/list?search=${encodeURIComponent(searchQuery.value)}&elems=${limit.value}&page=${page.value}`
|
||||||
|
);
|
||||||
|
|
||||||
|
clients.value = result.items?.items || [];
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to fetch clients';
|
||||||
|
clients.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
watch(searchQuery, debounce(searchClients, 300));
|
||||||
|
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
|
||||||
|
const UInput = resolveComponent('UInput')
|
||||||
|
const UIcon = resolveComponent('UIcon')
|
||||||
|
const UButton = resolveComponent('UButton')
|
||||||
|
|
||||||
|
|
||||||
|
const sortField = computed({
|
||||||
|
get: () => [
|
||||||
|
route.query.sort as string | undefined,
|
||||||
|
route.query.direction as 'asc' | 'desc' | undefined
|
||||||
|
],
|
||||||
|
set: ([sort]: [string, 'asc' | 'desc']) => {
|
||||||
|
const currentSort = route.query.sort as string | undefined
|
||||||
|
const currentDirection = route.query.direction as 'asc' | 'desc' | undefined
|
||||||
|
const query = { ...route.query }
|
||||||
|
|
||||||
|
if (currentSort === sort) {
|
||||||
|
if (currentDirection === 'asc') query.direction = 'desc'
|
||||||
|
else if (currentDirection === 'desc') {
|
||||||
|
delete query.sort
|
||||||
|
delete query.direction
|
||||||
|
} else {
|
||||||
|
query.direction = 'asc'
|
||||||
|
query.sort = sort
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
query.sort = sort
|
||||||
|
query.direction = 'asc'
|
||||||
|
}
|
||||||
|
router.push({ query })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function getIcon(name: string) {
|
||||||
|
if (sortField.value[0] === name) {
|
||||||
|
if (sortField.value[1] === 'asc') return 'i-lucide-arrow-up-narrow-wide'
|
||||||
|
if (sortField.value[1] === 'desc') return 'i-lucide-arrow-down-wide-narrow'
|
||||||
|
}
|
||||||
|
return 'i-lucide-arrow-up-down'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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(k => {
|
||||||
|
if (!['page', 'sort', 'direction'].includes(k)) delete baseQuery[k]
|
||||||
|
})
|
||||||
|
router.push({ query: { ...baseQuery, ...val, page: 1 } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const updateFilter = debounce((columnId: string, val: string) => {
|
||||||
|
const newFilters = { ...filters.value }
|
||||||
|
if (val) newFilters[columnId] = val
|
||||||
|
else delete newFilters[columnId]
|
||||||
|
filters.value = newFilters
|
||||||
|
}, 400)
|
||||||
|
|
||||||
|
|
||||||
|
const columns: TableColumn<Customer>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'user_id',
|
||||||
|
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
|
||||||
|
h('div', {
|
||||||
|
class: 'flex items-center gap-2 cursor-pointer',
|
||||||
|
onClick: () => (sortField.value = ['user_id', 'asc'])
|
||||||
|
}, [h('span', 'Client ID'), h(UIcon, { name: getIcon('user_id') })]),
|
||||||
|
h(UInput, {
|
||||||
|
placeholder: 'Search...',
|
||||||
|
modelValue: filters.value[column.id] ?? '',
|
||||||
|
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
|
||||||
|
size: 'xs'
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
cell: ({ row }) => `#${row.getValue('user_id')}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
|
||||||
|
h('div', {
|
||||||
|
class: 'flex items-center gap-2 cursor-pointer',
|
||||||
|
onClick: () => (sortField.value = ['last_name, first_name', 'asc'])
|
||||||
|
}, [h('span', 'Name/Surname'), h(UIcon, { name: getIcon('name') })]),
|
||||||
|
h(UInput, {
|
||||||
|
placeholder: 'Search...',
|
||||||
|
modelValue: filters.value[column.id] ?? '',
|
||||||
|
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
|
||||||
|
size: 'xs'
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
cell: ({ row }) => `${row.original.first_name} ${row.original.last_name}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'email',
|
||||||
|
header: ({ column }) => h('div', { class: 'flex flex-col gap-1' }, [
|
||||||
|
h('div', {
|
||||||
|
class: 'flex items-center gap-2 cursor-pointer',
|
||||||
|
onClick: () => (sortField.value = ['email', 'asc'])
|
||||||
|
}, [h('span', 'Email'), h(UIcon, { name: getIcon('email') })]),
|
||||||
|
h(UInput, {
|
||||||
|
placeholder: 'Search...',
|
||||||
|
modelValue: filters.value[column.id] ?? '',
|
||||||
|
'onUpdate:modelValue': (val: string) => updateFilter(column.id, val),
|
||||||
|
size: 'xs'
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
cell: ({ row }) => row.getValue('email')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'count',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return h(UButton, {
|
||||||
|
// onClick: () => {
|
||||||
|
// goToProduct(row.original.product_id, row.original.link_rewrite)
|
||||||
|
// },
|
||||||
|
class: 'cursor-pointer',
|
||||||
|
color: 'info',
|
||||||
|
variant: 'soft'
|
||||||
|
}, () => 'Manage')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'profile',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const userId = row.original.user_id
|
||||||
|
return h(UButton, {
|
||||||
|
color: 'info',
|
||||||
|
size: 'sm',
|
||||||
|
variant: 'soft',
|
||||||
|
onClick: () => {
|
||||||
|
router.push({
|
||||||
|
name: 'customer-management-profile',
|
||||||
|
params: { user_id: userId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, () => 'Go to profile')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
input::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
bo/src/components/customer-management/Profile.vue
Normal file
11
bo/src/components/customer-management/Profile.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="Management || 'div'">
|
||||||
|
<div>customer-management</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Management from '@/layouts/management.vue';
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useCartStore } from '@/stores/cart'
|
import { useCartStore } from '@/stores/customer/cart'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useAddressStore } from '@/stores/address'
|
import { useAddressStore } from '@/stores/customer/address'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import Default from '@/layouts/default.vue'
|
import Default from '@/layouts/default.vue'
|
||||||
const addressStore = useAddressStore()
|
const addressStore = useAddressStore()
|
||||||
|
|||||||
@@ -152,8 +152,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import { useCartStore } from '@/stores/customer/cart'
|
||||||
import { useAddressStore } from '@/stores/address'
|
import { useAddressStore } from '@/stores/customer/address'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import Default from '@/layouts/default.vue'
|
import Default from '@/layouts/default.vue'
|
||||||
|
|||||||
@@ -1,75 +1,84 @@
|
|||||||
<template>
|
<template>
|
||||||
<component :is="Default || 'div'">
|
<component :is="Default || 'div'">
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="flex md:flex-row flex-col justify-between gap-8 my-6">
|
<div class="flex md:flex-row flex-col justify-between gap-8 my-6">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-8 flex items-center justify-center min-h-[300px] ">
|
<div
|
||||||
<img :src="selectedColor?.image || productData.image" :alt="productData.name"
|
class="bg-gray-100 dark:bg-gray-800 rounded-lg p-8 flex items-center justify-center min-h-[300px] ">
|
||||||
class="max-w-full h-auto object-contain" />
|
<img :src="selectedColor?.image || productData.image" :alt="productData.name"
|
||||||
</div>
|
class="max-w-full h-auto object-contain" />
|
||||||
</div>
|
|
||||||
<div class="flex-1 flex flex-col gap-4">
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{{ productData.name }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-300">
|
|
||||||
{{ productData.description }}
|
|
||||||
</p>
|
|
||||||
<div class="text-3xl font-bold text-(--text-sky-light) dark:text-(--text-sky-dark)">
|
|
||||||
{{ productData.price }}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Dimensions:</span>
|
|
||||||
<p class="font-medium dark:text-white">{{ productData.dimensions }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
</div>
|
||||||
<span class="text-gray-500 dark:text-gray-400">Seat Height:</span>
|
<div class="flex-1 flex flex-col gap-4">
|
||||||
<p class="font-medium dark:text-white">{{ productData.seatHeight }}</p>
|
<div class="flex justify-between items-center">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ productData.name }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<UIcon name="material-symbols:favorite"
|
||||||
|
class="cursor-pointer text-2xl transition hover:scale-110"
|
||||||
|
:class="productData.is_favorite ? 'text-red-500' : 'text-gray-400'"
|
||||||
|
@click="toggleFavorite" />
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300">
|
||||||
|
{{ productData.description }}
|
||||||
|
</p>
|
||||||
|
<div class="text-3xl font-bold text-(--text-sky-light) dark:text-(--text-sky-dark)">
|
||||||
|
{{ productData.price }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Dimensions:</span>
|
||||||
|
<p class="font-medium dark:text-white">{{ productData.dimensions }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Seat Height:</span>
|
||||||
|
<p class="font-medium dark:text-white">{{ productData.seatHeight }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex md:flex-row flex-col justify-between md:items-end items-start gap-5 md:gap-0 md:mb-8 mb-4">
|
||||||
<div class="flex md:flex-row flex-col justify-between md:items-end items-start gap-5 md:gap-0 md:mb-8 mb-4">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex flex-col gap-3">
|
<span class="text-sm text-(--text-sky-light) dark:text-(--text-sky-dark) ">Colors:</span>
|
||||||
<span class="text-sm text-(--text-sky-light) dark:text-(--text-sky-dark) ">Colors:</span>
|
<div class="flex gap-2">
|
||||||
<div class="flex gap-2">
|
<button v-for="color in productData.colors" :key="color.id" @click="selectedColor = color"
|
||||||
<button v-for="color in productData.colors" :key="color.id" @click="selectedColor = color"
|
class="w-10 h-10 border-2 transition-all" :class="selectedColor?.id === color.id
|
||||||
class="w-10 h-10 border-2 transition-all" :class="selectedColor?.id === color.id
|
? 'border-(--accent-blue-light) ring-2 ring-blue-600 ring-offset-2'
|
||||||
? 'border-(--accent-blue-light) ring-2 ring-blue-600 ring-offset-2'
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'"
|
||||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'"
|
:style="{ backgroundColor: color.hex }" :title="color.name" />
|
||||||
:style="{ backgroundColor: color.hex }" :title="color.name" />
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-5 items-end">
|
||||||
|
<UInputNumber v-model="value" />
|
||||||
|
<UButton color="primary"
|
||||||
|
class="px-14! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white">
|
||||||
|
Add to Cart
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-5 items-end">
|
<ProductCustomization />
|
||||||
<UInputNumber v-model="value" />
|
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
|
||||||
<UButton color="primary" class="px-14! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white">
|
<div class="mb-6 w-[100%] xl:w-[60%]">
|
||||||
Add to Cart
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-10 mb-8">
|
||||||
</UButton>
|
<UButton v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
|
||||||
|
'px-15 py-2 cursor-pointer sm:text-nowrap flex items-center! justify-center!',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-blue-600 hover:text-black hover:dark:text-white text-white'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
]" variant="ghost">
|
||||||
|
{{ tab.label }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
<div class="py-5 px-3 bg-(--second-light) dark:bg-(--main-dark) rounded-md">
|
||||||
|
<p class="dark:text-white whitespace-pre-line">
|
||||||
|
{{ activeTabContent }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
|
||||||
|
<ProductVariants />
|
||||||
</div>
|
</div>
|
||||||
<ProductCustomization />
|
|
||||||
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
|
|
||||||
<div class="mb-6 w-[100%] xl:w-[60%]">
|
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-10 mb-8">
|
|
||||||
<UButton v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
|
|
||||||
'px-15 py-2 cursor-pointer sm:text-nowrap flex items-center! justify-center!',
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'bg-blue-600 hover:text-black hover:dark:text-white text-white'
|
|
||||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
]" variant="ghost">
|
|
||||||
{{ tab.label }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
<div class="py-5 px-3 bg-(--second-light) dark:bg-(--main-dark) rounded-md">
|
|
||||||
<p class="dark:text-white whitespace-pre-line">
|
|
||||||
{{ activeTabContent }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
|
|
||||||
<ProductVariants />
|
|
||||||
</div>
|
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -78,6 +87,8 @@ import { ref, computed } from 'vue'
|
|||||||
import ProductCustomization from './components/ProductCustomization.vue'
|
import ProductCustomization from './components/ProductCustomization.vue'
|
||||||
import ProductVariants from './components/ProductVariants.vue'
|
import ProductVariants from './components/ProductVariants.vue'
|
||||||
import Default from '@/layouts/default.vue'
|
import Default from '@/layouts/default.vue'
|
||||||
|
import { useFetchJson } from '@/composable/useFetchJson'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
interface Color {
|
interface Color {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -97,6 +108,7 @@ interface ProductData {
|
|||||||
howToUseText: string
|
howToUseText: string
|
||||||
productDetailsText: string
|
productDetailsText: string
|
||||||
documentsText: string
|
documentsText: string
|
||||||
|
is_favorite: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTab = ref('description')
|
const activeTab = ref('description')
|
||||||
@@ -157,6 +169,24 @@ const activeTabContent = computed(() => {
|
|||||||
if (productData.colors.length > 0) {
|
if (productData.colors.length > 0) {
|
||||||
selectedColor.value = productData.colors[0] as Color
|
selectedColor.value = productData.colors[0] as Color
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
async function toggleFavorite() {
|
||||||
|
const url = `/api/v1/restricted/product/favorite/${route.params.product_id}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!productData.is_favorite) {
|
||||||
|
await useFetchJson(url, { method: 'POST' })
|
||||||
|
} else {
|
||||||
|
await useFetchJson(url, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
productData.is_favorite = !productData.is_favorite
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -11,27 +11,30 @@
|
|||||||
</template>
|
</template>
|
||||||
</UNavigationMenu> -->
|
</UNavigationMenu> -->
|
||||||
<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="customerProductStore.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>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="error" class="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
<div v-else-if="customerProductStore.error" class="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||||
{{ error }}
|
{{ customerProductStore.error }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="overflow-x-auto">
|
<div v-else class="overflow-x-auto">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<CategoryMenu />
|
<CategoryMenu />
|
||||||
<UTable :data="productsList" :columns="columns" class="flex-1">
|
<UTable :data="customerProductStore.productsList" :columns="columns" class="flex-1">
|
||||||
<template #expanded="{ row }">
|
<template #expanded="{ row }">
|
||||||
<UTable :data="productsList.slice(0, 3)" :columns="columnsChild" :ui="{
|
<UTable :data="customerProductStore.productsList.slice(0, 3)" :columns="columnsChild"
|
||||||
thead: 'hidden'
|
:ui="{
|
||||||
}" />
|
thead: 'hidden'
|
||||||
|
}" />
|
||||||
</template>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
</div>
|
</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="customerProductStore.total"
|
||||||
|
:page-size="customerProductStore.perPage" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="productsList.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div v-if="customerProductStore.productsList.length === 0"
|
||||||
|
class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
No products found
|
No products found
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,20 +45,15 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, h, resolveComponent, computed } from 'vue'
|
import { ref, watch, h, resolveComponent, computed } from 'vue'
|
||||||
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 CategoryMenu from '../inner/CategoryMenu.vue'
|
import CategoryMenu from '../inner/CategoryMenu.vue'
|
||||||
|
import { useCustomerProductStore } from '@/stores/customer/customer-product'
|
||||||
|
import type { Product } from '@/stores/customer/customer-product'
|
||||||
|
|
||||||
interface Product {
|
|
||||||
reference: number
|
|
||||||
product_id: number
|
|
||||||
name: string
|
|
||||||
image_link: string
|
|
||||||
link_rewrite: string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const customerProductStore = useCustomerProductStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -101,18 +99,6 @@ const sortField = computed({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const perPage = ref(15)
|
|
||||||
const total = ref(0)
|
|
||||||
|
|
||||||
interface ApiResponse {
|
|
||||||
message: string
|
|
||||||
items: Product[]
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const productsList = ref<Product[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const filters = computed<Record<string, string>>({
|
const filters = computed<Record<string, string>>({
|
||||||
get: () => {
|
get: () => {
|
||||||
const q = { ...route.query }
|
const q = { ...route.query }
|
||||||
@@ -157,36 +143,16 @@ const updateFilter = debounce((columnId: string, val: string) => {
|
|||||||
filters.value = newFilters
|
filters.value = newFilters
|
||||||
}, 400)
|
}, 400)
|
||||||
|
|
||||||
async function fetchProductList() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
|
|
||||||
Object.entries(route.query).forEach(([key, value]) => {
|
|
||||||
if (value) params.append(key, String(value))
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = `/api/v1/restricted/list/list-products?${params}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await useFetchJson<ApiResponse>(url)
|
|
||||||
productsList.value = response.items || []
|
|
||||||
total.value = response.count || 0
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error.value = e instanceof Error ? e.message : 'Failed to load products'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToProduct(productId: number) {
|
function goToProduct(productId: number) {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'product-detail',
|
name: 'customer-product-details',
|
||||||
params: { id: productId }
|
params: { product_id: productId }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const selectedCount = ref({
|
const selectedCount = ref({
|
||||||
product_id: null,
|
product_id: null,
|
||||||
count: 0
|
count: 0
|
||||||
@@ -205,7 +171,7 @@ const UInput = resolveComponent('UInput')
|
|||||||
const UButton = resolveComponent('UButton')
|
const UButton = resolveComponent('UButton')
|
||||||
const UIcon = resolveComponent('UIcon')
|
const UIcon = resolveComponent('UIcon')
|
||||||
|
|
||||||
const columns: TableColumn<Payment>[] = [
|
const columns: TableColumn<Product>[] = [
|
||||||
{
|
{
|
||||||
id: 'expand',
|
id: 'expand',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
@@ -351,6 +317,35 @@ const columns: TableColumn<Payment>[] = [
|
|||||||
variant: 'solid'
|
variant: 'solid'
|
||||||
}, 'Add to cart')
|
}, 'Add to cart')
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'count',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return h(UButton, {
|
||||||
|
onClick: () => {
|
||||||
|
goToProduct(row.original.product_id)
|
||||||
|
},
|
||||||
|
class: 'cursor-pointer',
|
||||||
|
color: 'info',
|
||||||
|
variant: 'soft'
|
||||||
|
}, () => 'Show product')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'counta',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return h(UIcon, {
|
||||||
|
onClick: () => customerProductStore.toggleFavorite(row.original),
|
||||||
|
class: [
|
||||||
|
'cursor-pointer text-[20px] transition-transform duration-200 hover:scale-125',
|
||||||
|
row.original.is_favorite ? 'text-red-500' : 'text-blue-500'
|
||||||
|
],
|
||||||
|
name: 'material-symbols:favorite',
|
||||||
|
variant: 'soft',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -417,13 +412,27 @@ const columnsChild: TableColumn<Payment>[] = [
|
|||||||
variant: 'solid'
|
variant: 'solid'
|
||||||
}, 'Add to cart')
|
}, 'Add to cart')
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'count',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return h(UButton, {
|
||||||
|
onClick: () => {
|
||||||
|
goToProduct(row.original.product_id)
|
||||||
|
},
|
||||||
|
class: 'cursor-pointer',
|
||||||
|
color: 'info',
|
||||||
|
variant: 'soft'
|
||||||
|
}, () => 'Show product')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query,
|
() => route.query,
|
||||||
() => {
|
() => {
|
||||||
fetchProductList()
|
customerProductStore.fetchProductList()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useCustomerStore } from '@/stores/customer'
|
import { useCustomerStore } from '@/stores/customer'
|
||||||
import { useAddressStore } from '@/stores/address'
|
import { useAddressStore } from '@/stores/customer/address'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import Default from '@/layouts/default.vue'
|
import Default from '@/layouts/default.vue'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -116,9 +116,9 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useCustomerStore } from '@/stores/customer'
|
import { useCustomerStore } from '@/stores/customer'
|
||||||
import { useAddressStore } from '@/stores/address'
|
import { useAddressStore } from '@/stores/customer/address'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useCartStore } from '@/stores/cart'
|
import { useCartStore } from '@/stores/customer/cart'
|
||||||
import Default from '@/layouts/default.vue'
|
import Default from '@/layouts/default.vue'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const customerStore = useCustomerStore()
|
const customerStore = useCustomerStore()
|
||||||
|
|||||||
161
bo/src/components/customer/StorageFileBrowser.vue
Normal file
161
bo/src/components/customer/StorageFileBrowser.vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="Default || 'div'">
|
||||||
|
<div class="p-4">
|
||||||
|
<div v-if="loading" class="flex justify-center py-8">
|
||||||
|
<ULoader />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" class="text-red-500">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<UTree v-if="showTree" :items="treeItems" v-model:expanded="expandedFolders" :key="treeKey" @toggle="onToggle" :get-key="item => item.value">
|
||||||
|
<template #item-wrapper="{ item }">
|
||||||
|
<div class="flex items-start cursor-pointer" @click.stop="!item.isFolder">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<UIcon :name="item.icon" :size="30" />
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
<span class="text-[15px] font-medium">
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
<UButton v-if="!item.isFolder && item.fileName" size="xxs" color="neutral"
|
||||||
|
variant="outline" icon="i-lucide-download"
|
||||||
|
@click.stop="downloadFile(item.path, item.fileName)" :ui="{ base: 'ring-0!' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTree>
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useFetchJson } from '@/composable/useFetchJson'
|
||||||
|
import Default from '@/layouts/default.vue'
|
||||||
|
|
||||||
|
interface FileItemRaw {
|
||||||
|
Name: string
|
||||||
|
IsFolder: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
name: string
|
||||||
|
type: 'file' | 'folder'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeItem {
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
children?: TreeItem[]
|
||||||
|
isFolder: boolean
|
||||||
|
path: string
|
||||||
|
value: string
|
||||||
|
fileName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ initialPath?: string }>()
|
||||||
|
|
||||||
|
const currentPath = ref(props.initialPath || '')
|
||||||
|
|
||||||
|
const allData = ref<Record<string, FileItem[]>>({})
|
||||||
|
const expandedFolders = ref<string[]>([])
|
||||||
|
const treeKey = ref(0)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const showTree = computed(() => !error.value)
|
||||||
|
|
||||||
|
async function fetchFolderContents(path: string): Promise<FileItem[]> {
|
||||||
|
const url = `/api/v1/restricted/storage/list-content/${path}`
|
||||||
|
const data = await useFetchJson<FileItemRaw[]>(url)
|
||||||
|
|
||||||
|
return (data.items || []).map(i => ({
|
||||||
|
name: i.Name,
|
||||||
|
type: i.IsFolder ? 'folder' : 'file'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFolder(path: string) {
|
||||||
|
if (allData.value[path]) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await fetchFolderContents(path)
|
||||||
|
allData.value[path] = items
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load folder contents'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(path: string): TreeItem[] {
|
||||||
|
const items = allData.value[path] || []
|
||||||
|
|
||||||
|
return items.map(item => {
|
||||||
|
const itemPath = path ? `${path}/${item.name}` : item.name
|
||||||
|
const isFolder = item.type === 'folder'
|
||||||
|
|
||||||
|
const isExpanded = expandedFolders.value.includes(itemPath)
|
||||||
|
const isLoaded = !!allData.value[itemPath]
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: item.name,
|
||||||
|
icon: isFolder ? 'fxemoji:folder' : 'flat-color-icons:file',
|
||||||
|
isFolder,
|
||||||
|
path: isFolder ? itemPath : path,
|
||||||
|
value: itemPath,
|
||||||
|
fileName: isFolder ? undefined : item.name,
|
||||||
|
|
||||||
|
children: isFolder && isExpanded && isLoaded
|
||||||
|
? buildTree(itemPath)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeItems = computed(() => buildTree(currentPath.value))
|
||||||
|
|
||||||
|
async function toggleFolder(item: TreeItem) {
|
||||||
|
if (!item.isFolder) return
|
||||||
|
|
||||||
|
const isOpen = expandedFolders.value.includes(item.value)
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
await loadFolder(item.value)
|
||||||
|
treeKey.value++
|
||||||
|
} else {
|
||||||
|
treeKey.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggle(_event: unknown, item: TreeItem) {
|
||||||
|
console.log('Toggle:', item)
|
||||||
|
if (item.isFolder) {
|
||||||
|
toggleFolder(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(path: string, fileName: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/restricted/storage/download-file/${path}/${fileName}`)
|
||||||
|
if (!response.ok) throw new Error('Download failed')
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = fileName
|
||||||
|
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert('Download failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFolder(currentPath.value)
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<UNavigationMenu orientation="vertical" type="single" :items="items" class="data-[orientation=vertical]:w-72" />
|
<UNavigationMenu orientation="vertical" type="single" :items="items" class="data-[orientation=vertical]:w-72" :ui="{
|
||||||
|
root:''
|
||||||
|
}"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -32,6 +32,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/admin/theme'
|
||||||
const themeStorage = useThemeStore()
|
const themeStorage = useThemeStore()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export async function useFetchJson<T = unknown>(url: string, opt?: RequestInit):
|
|||||||
|
|
||||||
// Handle 401 — access token expired; try to refresh via the HTTPOnly refresh_token cookie
|
// Handle 401 — access token expired; try to refresh via the HTTPOnly refresh_token cookie
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
const { useAuthStore } = await import('@/stores/auth')
|
const { useAuthStore } = await import('../stores/customer/auth')
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const refreshed = await authStore.refreshAccessToken()
|
const refreshed = await authStore.refreshAccessToken()
|
||||||
|
|||||||
@@ -23,18 +23,23 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<UDropdownMenu :items="userItems" :content="{ align: 'center', collisionPadding: 12 }"
|
<UDropdownMenu :items="userItems" :content="{ align: 'center', collisionPadding: 12 }"
|
||||||
:ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-48' }">
|
:ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-48' }">
|
||||||
<UButton v-bind="user" :label="user?.name" trailing-icon="i-lucide-chevrons-up-down" color="neutral"
|
<UButton v-bind="userStore.user" :label="userStore.user?.email" trailing-icon="i-lucide-chevrons-up-down"
|
||||||
variant="ghost" square class="w-full data-[state=open]:bg-elevated overflow-hidden" :ui="{
|
color="neutral" variant="ghost" square class="w-full data-[state=open]:bg-elevated overflow-hidden" :ui="{
|
||||||
trailingIcon: 'text-dimmed ms-auto'
|
trailingIcon: 'text-dimmed ms-auto'
|
||||||
}" />
|
}" />
|
||||||
</UDropdownMenu>
|
</UDropdownMenu>
|
||||||
|
<!-- first_name: '', last_name: '' -->
|
||||||
</template>
|
</template>
|
||||||
</USidebar>
|
</USidebar>
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col">
|
<div class="flex-1 flex flex-col">
|
||||||
<div class="flex h-(--ui-header-height) shrink-0 items-center justify-between px-4 border-b border-default">
|
<div class="flex h-(--ui-header-height) shrink-0 items-center justify-between px-4 border-b border-default">
|
||||||
<UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar"
|
<div class="flex items-center gap-2">
|
||||||
@click="open = !open" />
|
<UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar"
|
||||||
|
@click="open = !open" />
|
||||||
|
<span class="text-[20px] font-medium">{{ pageTitle }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-12">
|
<div class="hidden md:flex items-center gap-12">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<CountryCurrencySwitch />
|
<CountryCurrencySwitch />
|
||||||
@@ -43,14 +48,15 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ThemeSwitch />
|
<ThemeSwitch />
|
||||||
<button v-if="authStore.isAuthenticated" @click="authStore.logout()"
|
<button v-if="authStore.isAuthenticated" @click="authStore.logout()"
|
||||||
class="px-3 py-1.5 text-sm font-medium text-black dark:text-white hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors border border-(--border-light) dark:border-(--border-dark) whitespace-nowrap">
|
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium text-black dark:text-white hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors border border-(--border-light) dark:border-(--border-dark) whitespace-nowrap">
|
||||||
{{ $t('general.logout') }}
|
{{ $t('general.logout') }}
|
||||||
|
<UIcon name="ic:baseline-logout" class="text-red-700 dark:text-red-500"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 p-4 bg-slate-50">
|
<div class="flex-1 p-4 bg-slate-50 dark:bg-(--main-dark)">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,10 +68,16 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useColorMode } from '@vueuse/core'
|
import { useColorMode } from '@vueuse/core'
|
||||||
import type { DropdownMenuItem, NavigationMenuItem } from '@nuxt/ui'
|
import type { DropdownMenuItem, NavigationMenuItem } from '@nuxt/ui'
|
||||||
import { defineShortcuts, extractShortcuts } from '@nuxt/ui/runtime/composables/defineShortcuts.js'
|
import { defineShortcuts, extractShortcuts } from '@nuxt/ui/runtime/composables/defineShortcuts.js'
|
||||||
import { LabelTrans, TopMenuItem } from '@/types'
|
import { useAuthStore } from '../stores/customer/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const pageTitle = computed(() => route.meta.name ?? 'Default Page')
|
||||||
|
await userStore.getUser()
|
||||||
|
|
||||||
const open = ref(true)
|
const open = ref(true)
|
||||||
const authStore = useAuthStore()
|
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
const teams = ref([
|
const teams = ref([
|
||||||
@@ -152,13 +164,14 @@ function getItems(state: 'collapsed' | 'expanded') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { currentLang } from '@/router/langs'
|
import { currentLang } from '@/router/langs'
|
||||||
import { useFetchJson } from '@/composable/useFetchJson'
|
import { useFetchJson } from '@/composable/useFetchJson'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import CountryCurrencySwitch from '@/components/inner/CountryCurrencySwitch.vue'
|
import CountryCurrencySwitch from '@/components/inner/CountryCurrencySwitch.vue'
|
||||||
import LangSwitch from '@/components/inner/LangSwitch.vue'
|
import LangSwitch from '@/components/inner/LangSwitch.vue'
|
||||||
import ThemeSwitch from '@/components/inner/ThemeSwitch.vue'
|
import ThemeSwitch from '@/components/inner/ThemeSwitch.vue'
|
||||||
|
import type { LabelTrans, TopMenuItem } from '@/types'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -217,13 +230,6 @@ function transformMenu(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = ref({
|
|
||||||
name: 'Benjamin Canac',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://github.com/benjamincanac.png',
|
|
||||||
alt: 'Benjamin Canac'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const userItems = computed<DropdownMenuItem[][]>(() => [
|
const userItems = computed<DropdownMenuItem[][]>(() => [
|
||||||
[
|
[
|
||||||
|
|||||||
311
bo/src/layouts/management.vue
Normal file
311
bo/src/layouts/management.vue
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-1 overflow-x-hidden h-svh">
|
||||||
|
<USidebar v-model:open="open" collapsible="icon" rail :ui="{
|
||||||
|
container: 'h-full z-80',
|
||||||
|
inner: 'bg-elevated/25 divide-transparent',
|
||||||
|
body: 'py-0'
|
||||||
|
}">
|
||||||
|
<template #header>
|
||||||
|
<UDropdownMenu :items="teamsItems" :content="{ align: 'start', collisionPadding: 12 }"
|
||||||
|
:ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-48' }">
|
||||||
|
<UButton v-bind="selectedTeam" trailing-icon="i-lucide-chevrons-up-down" color="neutral"
|
||||||
|
variant="ghost" square class="w-full data-[state=open]:bg-elevated overflow-hidden" :ui="{
|
||||||
|
trailingIcon: 'text-dimmed ms-auto'
|
||||||
|
}" />
|
||||||
|
</UDropdownMenu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default="{ state }">
|
||||||
|
<UNavigationMenu :key="state" :items="menuItems" orientation="vertical"
|
||||||
|
:ui="{ link: 'p-1.5 overflow-hidden' }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<UDropdownMenu :items="userItems" :content="{ align: 'center', collisionPadding: 12 }"
|
||||||
|
:ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-48' }">
|
||||||
|
<UButton v-bind="userStore.user" :label="userStore.user?.email"
|
||||||
|
trailing-icon="i-lucide-chevrons-up-down" color="neutral" variant="ghost" square
|
||||||
|
class="w-full data-[state=open]:bg-elevated overflow-hidden" :ui="{
|
||||||
|
trailingIcon: 'text-dimmed ms-auto'
|
||||||
|
}" />
|
||||||
|
</UDropdownMenu>
|
||||||
|
<!-- first_name: '', last_name: '' -->
|
||||||
|
</template>
|
||||||
|
</USidebar>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<div class="flex h-(--ui-header-height) shrink-0 items-center justify-between px-4 border-b border-default">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar"
|
||||||
|
@click="open = !open" />
|
||||||
|
<p class="font-bold text-xl">Customer-Management: <span class="text-[20px] font-medium">{{ pageTitle
|
||||||
|
}}</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:flex items-center gap-12">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<CountryCurrencySwitch />
|
||||||
|
<LangSwitch />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ThemeSwitch />
|
||||||
|
<button v-if="authStore.isAuthenticated" @click="authStore.logout()"
|
||||||
|
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium text-black dark:text-white hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors border border-(--border-light) dark:border-(--border-dark) whitespace-nowrap">
|
||||||
|
{{ $t('general.logout') }}
|
||||||
|
<UIcon name="ic:baseline-logout" class="text-red-700 dark:text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 p-4 bg-slate-50 dark:bg-(--main-dark)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useColorMode } from '@vueuse/core'
|
||||||
|
import type { DropdownMenuItem, NavigationMenuItem } from '@nuxt/ui'
|
||||||
|
import { defineShortcuts, extractShortcuts } from '@nuxt/ui/runtime/composables/defineShortcuts.js'
|
||||||
|
import { useAuthStore } from '../stores/customer/auth'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const pageTitle = computed(() => route.meta.name ?? 'Default Page')
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const open = ref(true)
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
const teams = ref([
|
||||||
|
{
|
||||||
|
label: 'Nuxt',
|
||||||
|
avatar: {
|
||||||
|
src: 'https://github.com/nuxt.png',
|
||||||
|
alt: 'Nuxt'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Vue',
|
||||||
|
avatar: {
|
||||||
|
src: 'https://github.com/vuejs.png',
|
||||||
|
alt: 'Vue'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'UnJS',
|
||||||
|
avatar: {
|
||||||
|
src: 'https://github.com/unjs.png',
|
||||||
|
alt: 'UnJS'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
const selectedTeam = ref(teams.value[0])
|
||||||
|
|
||||||
|
const teamsItems = computed<DropdownMenuItem[][]>(() => {
|
||||||
|
return [
|
||||||
|
teams.value.map((team, index) => ({
|
||||||
|
...team,
|
||||||
|
kbds: ['meta', String(index + 1)],
|
||||||
|
onSelect() {
|
||||||
|
selectedTeam.value = team
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Create team',
|
||||||
|
icon: 'i-lucide-circle-plus'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
function getItems(state: 'collapsed' | 'expanded') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Inbox',
|
||||||
|
icon: 'i-lucide-inbox',
|
||||||
|
badge: '4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Issues',
|
||||||
|
icon: 'i-lucide-square-dot'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Activity',
|
||||||
|
icon: 'i-lucide-square-activity'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
icon: 'i-lucide-settings',
|
||||||
|
defaultOpen: true,
|
||||||
|
children:
|
||||||
|
state === 'expanded'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'General',
|
||||||
|
icon: 'i-lucide-house'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Team',
|
||||||
|
icon: 'i-lucide-users'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Billing',
|
||||||
|
icon: 'i-lucide-credit-card'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
] satisfies NavigationMenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { currentLang } from '@/router/langs'
|
||||||
|
import { useFetchJson } from '@/composable/useFetchJson'
|
||||||
|
import CountryCurrencySwitch from '@/components/inner/CountryCurrencySwitch.vue'
|
||||||
|
import LangSwitch from '@/components/inner/LangSwitch.vue'
|
||||||
|
import ThemeSwitch from '@/components/inner/ThemeSwitch.vue'
|
||||||
|
import type { LabelTrans, TopMenuItem } from '@/types'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { watch } from 'vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const menu = ref<TopMenuItem[] | null>(null)
|
||||||
|
|
||||||
|
const Id =Number(route.params.user_id)
|
||||||
|
|
||||||
|
async function cmGetTopMenu() {
|
||||||
|
try {
|
||||||
|
const { items } = await useFetchJson<TopMenuItem[]>(`/api/v1/restricted/menu/get-top-menu?target_user_id=${Id}`)
|
||||||
|
|
||||||
|
menu.value = items
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(route)
|
||||||
|
watch(
|
||||||
|
() => route.params.user_id,
|
||||||
|
() => {
|
||||||
|
if (route.params.user_id) {
|
||||||
|
cmGetTopMenu()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const menuItems = computed(() => {
|
||||||
|
if (!menu.value?.length) return []
|
||||||
|
|
||||||
|
return transformMenu(
|
||||||
|
menu.value || [],
|
||||||
|
currentLang.value?.iso_code
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function transformMenu(
|
||||||
|
items: TopMenuItem[],
|
||||||
|
locale: string | undefined
|
||||||
|
): NavigationMenuItem[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
const route: NavigationMenuItem = {
|
||||||
|
icon: item.label.icon || 'i-lucide-house',
|
||||||
|
label:
|
||||||
|
item.label.trans?.[locale as keyof LabelTrans]?.label ||
|
||||||
|
item.label.trans?.en?.label ||
|
||||||
|
'—',
|
||||||
|
children: item.children
|
||||||
|
? transformMenu(item.children, locale)
|
||||||
|
: undefined,
|
||||||
|
onSelect: () => {
|
||||||
|
router.push({
|
||||||
|
name: item.params.route.name,
|
||||||
|
params: {
|
||||||
|
...(item.params.route.params || {}),
|
||||||
|
locale: currentLang.value?.iso_code
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return route
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const userItems = computed<DropdownMenuItem[][]>(() => [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Profile',
|
||||||
|
icon: 'i-lucide-user'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Billing',
|
||||||
|
icon: 'i-lucide-credit-card'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
icon: 'i-lucide-settings',
|
||||||
|
to: '/settings'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Appearance',
|
||||||
|
icon: 'i-lucide-sun-moon',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Light',
|
||||||
|
icon: 'i-lucide-sun',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: colorMode.value === 'light',
|
||||||
|
onUpdateChecked(checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
colorMode.preference = 'light'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSelect(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dark',
|
||||||
|
icon: 'i-lucide-moon',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: colorMode.value === 'dark',
|
||||||
|
onUpdateChecked(checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
colorMode.preference = 'dark'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSelect(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'GitHub',
|
||||||
|
icon: 'i-simple-icons-github',
|
||||||
|
to: 'https://github.com/nuxt/ui',
|
||||||
|
target: '_blank'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Log out',
|
||||||
|
icon: 'i-lucide-log-out'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
defineShortcuts(extractShortcuts(teamsItems.value))
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { currentLang, langs, switchLocalization } from './langs'
|
import { currentLang, langs, switchLocalization } from './langs'
|
||||||
import { getSettings } from './settings'
|
import { getSettings } from './settings'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/customer/auth'
|
||||||
import { getRoutes } from './menu'
|
import { getRoutes } from './menu'
|
||||||
|
|
||||||
function isAuthenticated(): boolean {
|
function isAuthenticated(): boolean {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { Address } from './address'
|
|
||||||
|
|
||||||
export interface CustomerData {
|
export interface CustomerData {
|
||||||
companyName: string
|
companyName: string
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useFetchJson } from '@/composable/useFetchJson'
|
import { useFetchJson } from '@/composable/useFetchJson'
|
||||||
|
import { useUserStore } from '../user'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
@@ -41,6 +42,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
_isAuthenticated.value = readIsAuthenticatedCookie()
|
_isAuthenticated.value = readIsAuthenticatedCookie()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// const auth = useAuthStore()
|
||||||
|
// const userStore = useUserStore()
|
||||||
async function login(email: string, password: string) {
|
async function login(email: string, password: string) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -60,6 +63,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
user.value = response.user
|
user.value = response.user
|
||||||
_syncAuthState()
|
_syncAuthState()
|
||||||
|
// await userStore.getUser()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
86
bo/src/stores/customer/customer-product.ts
Normal file
86
bo/src/stores/customer/customer-product.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useFetchJson } from '@/composable/useFetchJson'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number
|
||||||
|
image: string
|
||||||
|
name: string
|
||||||
|
productDetails?: string
|
||||||
|
product_id: number
|
||||||
|
is_favorite?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductResponse {
|
||||||
|
items: Product[]
|
||||||
|
items_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse {
|
||||||
|
message: string
|
||||||
|
items: Product[]
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCustomerProductStore = defineStore('customer-product', () => {
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const route = useRoute()
|
||||||
|
const productsList = ref<Product[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const perPage = ref(15)
|
||||||
|
|
||||||
|
async function fetchProductList() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
Object.entries(route.query).forEach(([key, value]) => {
|
||||||
|
if (value) params.append(key, String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = `/api/v1/restricted/product/list?${params}`
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await useFetchJson<ApiResponse>(url)
|
||||||
|
productsList.value = response.items || []
|
||||||
|
total.value = response.count || 0
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load products'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFavorite(product: Product) {
|
||||||
|
const productId = product.product_id
|
||||||
|
const isFavorite = product.is_favorite
|
||||||
|
|
||||||
|
const url = `/api/v1/restricted/product/favorite/${productId}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isFavorite) {
|
||||||
|
await useFetchJson(url, { method: 'POST' })
|
||||||
|
} else {
|
||||||
|
await useFetchJson(url, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
product.is_favorite = !isFavorite
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to update favorite'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchProductList,
|
||||||
|
toggleFavorite,
|
||||||
|
productsList,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
perPage
|
||||||
|
}
|
||||||
|
})
|
||||||
32
bo/src/stores/user.ts
Normal file
32
bo/src/stores/user.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { User } from '@/types/user'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useFetchJson } from '@/composable/useFetchJson'
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', () => {
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
|
||||||
|
async function getUser() {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await useFetchJson<User>(`/api/v1/restricted/customer`)
|
||||||
|
console.log('getUser API response:', data)
|
||||||
|
|
||||||
|
const response: User = (data as any).items ?? data
|
||||||
|
console.log('User response:', response)
|
||||||
|
user.value = response
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.message ?? 'Unknown error'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
user,
|
||||||
|
getUser
|
||||||
|
}
|
||||||
|
})
|
||||||
23
bo/src/types/user.d.ts
vendored
Normal file
23
bo/src/types/user.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
company_name?: string
|
||||||
|
company_email?: string
|
||||||
|
company_address?: Address
|
||||||
|
billing_address?: Address
|
||||||
|
regon?: string
|
||||||
|
nip?: string
|
||||||
|
vat?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
user_id: number
|
||||||
|
email: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// import { useRoute } from 'vue-router';
|
// import { useRoute } from 'vue-router';
|
||||||
import Default from '@/layouts/default.vue';
|
import Default from '@/layouts/default.vue';
|
||||||
import { useCategoryStore } from '@/stores/category';
|
import { useCategoryStore } from '@/stores/admin/category';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
// const route = useRoute()
|
// const route = useRoute()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/customer/auth'
|
||||||
import { useValidation } from '@/composable/useValidation'
|
import { useValidation } from '@/composable/useValidation'
|
||||||
import type { FormError } from '@nuxt/ui'
|
import type { FormError } from '@nuxt/ui'
|
||||||
import { i18n } from '@/plugins/02_i18n'
|
import { i18n } from '@/plugins/02_i18n'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/customer/auth'
|
||||||
import { useValidation } from '@/composable/useValidation'
|
import { useValidation } from '@/composable/useValidation'
|
||||||
import type { FormError } from '@nuxt/ui'
|
import type { FormError } from '@nuxt/ui'
|
||||||
import { i18n } from '@/plugins/02_i18n'
|
import { i18n } from '@/plugins/02_i18n'
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, defineAsyncComponent } from 'vue'
|
import { ref, computed, defineAsyncComponent } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/customer/auth'
|
||||||
import { useValidation } from '@/composable/useValidation'
|
import { useValidation } from '@/composable/useValidation'
|
||||||
import type { FormError } from '@nuxt/ui'
|
import type { FormError } from '@nuxt/ui'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
LinearScale,
|
LinearScale,
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import { getYears, getQuarters, getIssues, type QuarterData, type IssueTimeSummary } from '@/composable/useRepoApi'
|
import { getYears, getQuarters, getIssues, type QuarterData, type IssueTimeSummary } from '@/composable/useRepoApi'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/customer/auth'
|
||||||
import { i18n } from '@/plugins/02_i18n'
|
import { i18n } from '@/plugins/02_i18n'
|
||||||
import type { TableColumn } from '@nuxt/ui'
|
import type { TableColumn } from '@nuxt/ui'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/customer/auth'
|
||||||
import { useValidation } from '@/composable/useValidation'
|
import { useValidation } from '@/composable/useValidation'
|
||||||
import type { FormError } from '@nuxt/ui'
|
import type { FormError } from '@nuxt/ui'
|
||||||
import { i18n } from '@/plugins/02_i18n'
|
import { i18n } from '@/plugins/02_i18n'
|
||||||
|
|||||||
12
bo/src/views/StorageView.vue
Normal file
12
bo/src/views/StorageView.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<Default>
|
||||||
|
<div class="h-full">
|
||||||
|
<StorageFileBrowser initial-path="dest/src" />
|
||||||
|
</div>
|
||||||
|
</Default>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Default from '@/layouts/default.vue'
|
||||||
|
import StorageFileBrowser from '@/components/customer/StorageFileBrowser.vue'
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user