fix: added page PageProductCardFull and Addresses
This commit is contained in:
@@ -22,6 +22,12 @@ const authStore = useAuthStore()
|
||||
<RouterLink :to="{ name: 'product-detail' }">
|
||||
product detail
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'product-card-full' }">
|
||||
ProductCardFull
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'addresses' }">
|
||||
Addresses
|
||||
</RouterLink>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Language Switcher -->
|
||||
<LangSwitch />
|
||||
|
||||
222
bo/src/components/admin/ProductDetailView.vue
Normal file
222
bo/src/components/admin/ProductDetailView.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="container my-10 ">
|
||||
|
||||
<div
|
||||
class="flex items-end justify-between gap-4 mb-6 bg-(--second-light) dark:bg-(--main-dark) border border-(--border-light) dark:border-(--border-dark) p-4 rounded-md">
|
||||
<div class="flex items-end gap-3">
|
||||
<USelect v-model="selectedLanguage" :items="availableLangs" variant="outline" class="w-40!" valueKey="iso_code">
|
||||
<template #default="{ modelValue }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-md">{{availableLangs.find(x => x.iso_code == modelValue)?.flag}}</span>
|
||||
<span class="font-medium dark:text-white text-black">{{availableLangs.find(x => x.iso_code ==
|
||||
modelValue)?.name}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item-leading="{ item }">
|
||||
<div class="flex items-center rounded-md cursor-pointer transition-colors">
|
||||
<span class="text-md">{{ item.flag }}</span>
|
||||
<span class="ml-2 dark:text-white text-black font-medium">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</USelect>
|
||||
</div>
|
||||
<UButton @click="translateToSelectedLanguage" color="primary" :loading="translating"
|
||||
class="text-white bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) px-12!">
|
||||
Translate
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div v-if="translating" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="flex flex-col items-center gap-4 p-8 bg-(--main-light) dark:bg-(--main-dark) rounded-lg shadow-xl">
|
||||
<UIcon name="svg-spinners:ring-resize" class="text-4xl text-primary" />
|
||||
<p class="text-lg font-medium dark:text-white text-black">Translating...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-30">
|
||||
<p class="p-80 bg-(--second-light)">img</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-[25px] font-bold text-black dark:text-white">
|
||||
{{ productStore.productDescription.name }}
|
||||
</p>
|
||||
<p v-html="productStore.productDescription.description_short" class="text-black dark:text-white"></p>
|
||||
<div class="space-y-[10px]">
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon name="lets-icons:done-ring-round-fill" class="text-[20px] text-green-600" />
|
||||
<p class="text-[16px] font-bold text-(--accent-blue-light) dark:text-(--accent-blue-dark)">
|
||||
{{ productStore.productDescription.available_now }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon name="marketeq:car-shipping" class="text-[25px] text-green-600" />
|
||||
<p class="text-[18px] font-bold text-black dark:text-white">
|
||||
{{ productStore.productDescription.delivery_in_stock }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-16">
|
||||
<div class="flex gap-4 my-6">
|
||||
<UButton @click="activeTab = 'description'"
|
||||
:class="['cursor-pointer', activeTab === 'description' ? 'bg-blue-500 text-white' : '']" color="neutral"
|
||||
variant="outline">
|
||||
<p class="dark:text-white">Description</p>
|
||||
</UButton>
|
||||
|
||||
<UButton @click="activeTab = 'usage'"
|
||||
:class="['cursor-pointer', activeTab === 'usage' ? 'bg-blue-500 text-white' : '']" color="neutral"
|
||||
variant="outline">
|
||||
<p class="dark:text-white">Usage</p>
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'usage'"
|
||||
class="px-8 py-4 border border-(--border-light) dark:border-(--border-dark) rounded-md bg-(--second-light) dark:bg-(--main-dark)">
|
||||
|
||||
<div class="flex justify-end items-center gap-3 mb-4">
|
||||
<UButton v-if="!isEditing" @click="enableEdit"
|
||||
class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
|
||||
<p class="text-white">Change Text</p>
|
||||
<UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" />
|
||||
</UButton>
|
||||
<UButton v-if="isEditing" @click="saveText" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
|
||||
<p class="dark:text-white text-black">Save the edited text</p>
|
||||
</UButton>
|
||||
<UButton v-if="isEditing" @click="cancelEdit" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
|
||||
Cancel
|
||||
</UButton>
|
||||
</div>
|
||||
<p ref="usageRef" v-html="productStore.productDescription.usage"
|
||||
class="flex flex-col justify-center w-full text-start dark:text-white! text-black!"></p>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'description'"
|
||||
class="px-8 py-4 border border-(--border-light) dark:border-(--border-dark) rounded-md bg-(--second-light) dark:bg-(--main-dark)">
|
||||
<div class="flex items-center justify-end gap-3 mb-4">
|
||||
<UButton v-if="!descriptionEdit.isEditing.value" @click="enableDescriptionEdit"
|
||||
class="flex items-center gap-2 m-2 cursor-pointer bg-(--accent-blue-light)! dark:bg-(--accent-blue-dark)!">
|
||||
<p class="text-white">Change Text</p>
|
||||
<UIcon name="material-symbols-light:stylus-note-sharp" class="text-[30px] text-white!" />
|
||||
</UButton>
|
||||
<UButton v-if="descriptionEdit.isEditing.value" @click="saveDescription" color="neutral" variant="outline" class="p-2.5 cursor-pointer">
|
||||
<p class="dark:text-white text-black ">Save the edited text</p>
|
||||
</UButton>
|
||||
<UButton v-if="descriptionEdit.isEditing.value" @click="cancelDescriptionEdit" color="neutral" variant="outline" class="p-2.5 cursor-pointer">Cancel</UButton>
|
||||
</div>
|
||||
<div ref="descriptionRef" v-html="productStore.productDescription.description"
|
||||
class="flex flex-col justify-center dark:text-white text-black">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useProductStore } from '@/stores/product'
|
||||
import { useEditable } from '@/composable/useConteditable'
|
||||
import { langs } from '@/router/langs'
|
||||
import type { Language } from '@/types'
|
||||
|
||||
const activeTab = ref('description')
|
||||
const productStore = useProductStore()
|
||||
const translating = ref(false)
|
||||
|
||||
// const availableLangs = computed(() => {
|
||||
// return langs.filter((l: Language) => ['cs', 'pl', 'der'].includes(l.iso_code))
|
||||
// })
|
||||
|
||||
const isEditing = ref(false)
|
||||
|
||||
const availableLangs = computed(() => langs)
|
||||
|
||||
const selectedLanguage = ref('pl')
|
||||
|
||||
const currentLangId = ref(2)
|
||||
|
||||
const fetchForLanguage = async (langCode: string) => {
|
||||
const lang = langs.find((l: Language) => l.iso_code === langCode)
|
||||
if (lang) {
|
||||
await productStore.getProductDescription(lang.id)
|
||||
currentLangId.value = lang.id
|
||||
}
|
||||
}
|
||||
|
||||
const translateToSelectedLanguage = async () => {
|
||||
const targetLang = langs.find((l: Language) => l.iso_code === selectedLanguage.value)
|
||||
if (targetLang && currentLangId.value) {
|
||||
translating.value = true
|
||||
try {
|
||||
await productStore.translateProductDescription(currentLangId.value, targetLang.id)
|
||||
currentLangId.value = targetLang.id
|
||||
} finally {
|
||||
translating.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fetchForLanguage(selectedLanguage.value)
|
||||
const descriptionRef = ref<HTMLElement | null>(null)
|
||||
const usageRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const descriptionEdit = useEditable(descriptionRef)
|
||||
const usageEdit = useEditable(usageRef)
|
||||
|
||||
const originalDescription = ref('')
|
||||
const originalUsage = ref('')
|
||||
|
||||
const saveDescription = async () => {
|
||||
descriptionEdit.disableEdit()
|
||||
await productStore.saveProductDescription()
|
||||
}
|
||||
|
||||
const cancelDescriptionEdit = () => {
|
||||
if (descriptionRef.value) {
|
||||
descriptionRef.value.innerHTML = originalDescription.value
|
||||
}
|
||||
descriptionEdit.disableEdit()
|
||||
}
|
||||
|
||||
const enableDescriptionEdit = () => {
|
||||
if (descriptionRef.value) {
|
||||
originalDescription.value = descriptionRef.value.innerHTML
|
||||
}
|
||||
descriptionEdit.enableEdit()
|
||||
}
|
||||
|
||||
const enableEdit = () => {
|
||||
if (usageRef.value) {
|
||||
originalUsage.value = usageRef.value.innerHTML
|
||||
}
|
||||
isEditing.value = true
|
||||
usageEdit.enableEdit()
|
||||
}
|
||||
|
||||
const saveText = () => {
|
||||
usageEdit.disableEdit()
|
||||
isEditing.value = false
|
||||
productStore.saveProductDescription()
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
if (usageRef.value) {
|
||||
usageRef.value.innerHTML = originalUsage.value
|
||||
}
|
||||
usageEdit.disableEdit()
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.images {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 70px;
|
||||
margin: 20px 0 20px 0;
|
||||
}
|
||||
</style>
|
||||
257
bo/src/components/admin/ProductsView.vue
Normal file
257
bo/src/components/admin/ProductsView.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<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'
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
// 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>
|
||||
<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>
|
||||
</template>
|
||||
154
bo/src/components/customer/PageAddresses.vue
Normal file
154
bo/src/components/customer/PageAddresses.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="p-6 bg-white dark:bg-(--black) min-h-screen font-sans">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-black dark:text-white">
|
||||
{{ t('addresses.title') }}
|
||||
</h1>
|
||||
<UButton @click="showModal = true"
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2">
|
||||
<span class="i-lucide-plus" />
|
||||
{{ t('addresses.addNew') }}
|
||||
</UButton>
|
||||
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<UInput v-model="searchQuery" type="text" :placeholder="t('addresses.searchPlaceholder')"
|
||||
class="bg-white dark:bg-gray-800 text-black dark:text-white w-[30%]" />
|
||||
</div>
|
||||
<div v-if="paginatedAddresses.length > 0" class="space-y-4">
|
||||
<div v-for="address in paginatedAddresses" :key="address.id"
|
||||
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800 hover:shadow-md transition-shadow">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<p class="text-black dark:text-white mt-1">
|
||||
{{ address.street }}
|
||||
</p>
|
||||
<p class="text-black dark:text-white flex gap-2">
|
||||
{{ address.zipCode }}, <span>{{ address.city }}</span>
|
||||
</p>
|
||||
<p class="text-black dark:text-white">{{ address.country }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<span class="i-lucide-map-pin text-4xl mb-4 block" />
|
||||
<p class="text-lg">{{ t('addresses.noAddresses') }}</p>
|
||||
<UButton @click="showModal"
|
||||
class="mt-4 px-4 py-2 bg-primary text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
{{ t('addresses.addFirst') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<form @submit.prevent="saveAddress" class="space-y-4 mt-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black dark:text-white mb-1">Street *</label>
|
||||
<UInput v-model="formData.street" placeholder="Enter street" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black dark:text-white mb-1">Zip Code *</label>
|
||||
<UInput v-model="formData.zipCode" placeholder="Enter zip code" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black dark:text-white mb-1">City *</label>
|
||||
<UInput v-model="formData.city" placeholder="Enter city" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-black dark:text-white mb-1">Country *</label>
|
||||
<UInput v-model="formData.country" placeholder="Enter country" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<UButton color="neutral" variant="outline" @click="closeModal">Cancel</UButton>
|
||||
<UButton type="submit" color="primary">Save</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAddressStore, type Address, type AddressFormData } from '@/stores/address'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const addressStore = useAddressStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const showModal = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingAddressId = ref<number | null>(null)
|
||||
|
||||
const formData = ref<AddressFormData>({
|
||||
street: '',
|
||||
zipCode: '',
|
||||
city: '',
|
||||
country: ''
|
||||
})
|
||||
|
||||
const formErrors = ref<Record<string, string>>({})
|
||||
const searchQuery = ref('')
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(searchQuery, (newValue) => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
addressStore.setSearchQuery(newValue)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
const paginatedAddresses = computed(() => addressStore.paginatedAddresses)
|
||||
|
||||
function resetForm() {
|
||||
formData.value = {
|
||||
street: '',
|
||||
zipCode: '',
|
||||
city: '',
|
||||
country: ''
|
||||
}
|
||||
formErrors.value = {}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
formErrors.value = {}
|
||||
|
||||
if (!formData.value.street.trim()) {
|
||||
formErrors.value.street = 'Street is required'
|
||||
}
|
||||
if (!formData.value.zipCode.trim()) {
|
||||
formErrors.value.zipCode = 'Zip Code is required'
|
||||
}
|
||||
if (!formData.value.city.trim()) {
|
||||
formErrors.value.city = 'City is required'
|
||||
}
|
||||
if (!formData.value.country.trim()) {
|
||||
formErrors.value.country = 'Country is required'
|
||||
}
|
||||
|
||||
return Object.keys(formErrors.value).length === 0
|
||||
}
|
||||
|
||||
function saveAddress() {
|
||||
if (!validateForm()) return
|
||||
|
||||
if (isEditing.value && editingAddressId.value) {
|
||||
(editingAddressId.value, formData.value)
|
||||
} else {
|
||||
addressStore.addAddress(formData.value)
|
||||
}
|
||||
|
||||
closeModal()
|
||||
}
|
||||
|
||||
</script>
|
||||
164
bo/src/components/customer/PageProductCardFull.vue
Normal file
164
bo/src/components/customer/PageProductCardFull.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="container mt-14">
|
||||
<div class="flex justify-between gap-8 mb-6">
|
||||
<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]">
|
||||
<img :src="selectedColor?.image || productData.image" :alt="productData.name"
|
||||
class="max-w-full h-auto object-contain" />
|
||||
</div>
|
||||
</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-(--accent-blue-light) dark:text-(--accent-blue-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 class="flex justify-between items-end mb-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-sm text-(--accent-blue-light) dark:text-(--accent-blue-dark) ">Colors:</span>
|
||||
<div class="flex gap-2">
|
||||
<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
|
||||
? 'border-(--accent-blue-light) ring-2 ring-blue-600 ring-offset-2'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'"
|
||||
: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>
|
||||
<ProductCustomization />
|
||||
<hr class="border-t border-(--border-light) dark:border-(--border-dark) mb-8" />
|
||||
<div class="mb-6 w-[55%]">
|
||||
<div class="flex justify-between items-center gap-10 mb-8">
|
||||
<UButton v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id" :class="[
|
||||
'px-15 py-2 cursor-pointer',
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import ProductCustomization from './components/ProductCustomization.vue'
|
||||
import ProductVariants from './components/ProductVariants.vue'
|
||||
|
||||
interface Color {
|
||||
id: string
|
||||
name: string
|
||||
hex: string
|
||||
image: string
|
||||
}
|
||||
|
||||
interface ProductData {
|
||||
name: string
|
||||
description: string
|
||||
price: string
|
||||
dimensions: string
|
||||
seatHeight: string
|
||||
image: string
|
||||
colors: Color[]
|
||||
descriptionText: string
|
||||
howToUseText: string
|
||||
productDetailsText: string
|
||||
documentsText: string
|
||||
}
|
||||
|
||||
const activeTab = ref('description')
|
||||
const value = ref(5)
|
||||
const selectedColor = ref<Color | null>(null)
|
||||
|
||||
const productData: ProductData = {
|
||||
name: 'Larger Corner Sofa',
|
||||
description: 'The large corner sofa is a comfortable seating solution for young children. It is upholstered with phthalate-free PVC-coated material intended for medical products, making it very easy to clean and disinfect. The product is available in a wide range of colours (13 colours), allowing it to fit into any interior.',
|
||||
price: 'PLN 519.00 (VAT 23%)',
|
||||
dimensions: '65 x 65 x 120 cm',
|
||||
seatHeight: '45-55 cm',
|
||||
image: '/placeholder-chair.jpg',
|
||||
colors: [
|
||||
{ id: 'black', name: 'Black', hex: '#1a1a1a', image: '/chair-black.jpg' },
|
||||
{ id: 'gray', name: 'Gray', hex: '#6b7280', image: '/chair-gray.jpg' },
|
||||
{ id: 'blue', name: 'Blue', hex: '#3b82f6', image: '/chair-blue.jpg' },
|
||||
{ id: 'brown', name: 'Brown', hex: '#92400e', image: '/chair-brown.jpg' },
|
||||
],
|
||||
descriptionText: 'The large corner sofa is a comfortable seating solution for young children. It is upholstered with phthalate-free PVC-coated material intended for medical products, making it very easy to clean and disinfect. The product is available in a wide range of colours (13 colours), allowing it to fit into any interior',
|
||||
howToUseText: '1. Adjust the seat height using the lever under the seat.\n2. Set the lumbar support to your preferred position.\n3. Adjust the armrests for optimal arm support.\n4. Use the recline tension knob to adjust the backrest resistance.\n5. Lock the recline position when needed.',
|
||||
productDetailsText: '• Material: Mesh, Foam, Plastic\n• Max Load: 150 kg\n• Weight: 18 kg\n• Warranty: 2 years\n• Certifications: BIFMA, EN 1335',
|
||||
documentsText: '• Assembly Instructions (PDF)\n• User Manual (PDF)\n• Warranty Terms (PDF)\n• Safety Certificate (PDF)',
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'description', label: 'Description' },
|
||||
{ id: 'howToUse', label: 'How to Use' },
|
||||
{ id: 'productDetails', label: 'Product Details' },
|
||||
{ id: 'documents', label: 'Documents' },
|
||||
]
|
||||
|
||||
const userActions = [
|
||||
'View detailed product information',
|
||||
'Browse product images and available colors',
|
||||
'Check product dimensions and specifications',
|
||||
'Select a product variant',
|
||||
'Select quantity',
|
||||
'Add the product to the cart',
|
||||
'Navigate between product description, usage instructions, and product details',
|
||||
]
|
||||
|
||||
const activeTabContent = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'description':
|
||||
return productData.descriptionText
|
||||
case 'howToUse':
|
||||
return productData.howToUseText
|
||||
case 'productDetails':
|
||||
return productData.productDetailsText
|
||||
case 'documents':
|
||||
return productData.documentsText
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
if (productData.colors.length > 0) {
|
||||
selectedColor.value = productData.colors[0] as Color
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.product-card-full {
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="container flex flex-col gap-8">
|
||||
<div class="space-y-1 dark:text-white text-black">
|
||||
<p class="text-[24px] font-bold">Product customization</p>
|
||||
<p class="text-[15px]">Don't forget to save your customization to be able to add to cart</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-10">
|
||||
<UInput label="Podaj kolor kanapy narożnej" placeholder="Podaj kolor kanapy narożnej" class="dark:text-white text-black"/>
|
||||
<UInput label="Podaj kolor fotela" placeholder="Podaj kolor fotela" class="dark:text-white text-black"/>
|
||||
<UInput label="Podaj kolor kwadratu" placeholder="Podaj kolor kwadratu" class="dark:text-white text-black"/>
|
||||
</div>
|
||||
<div class="flex justify-end items-end mb-8">
|
||||
<UButton class="px-10! bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white">Save</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
57
bo/src/components/customer/components/ProductVariants.vue
Normal file
57
bo/src/components/customer/components/ProductVariants.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="container flex flex-col gap-8">
|
||||
<p class="text-[24px] font-bold dark:text-white text-black">Product Variants:</p>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="(variant, index) in variants" :key="index" class="flex gap-10">
|
||||
<div
|
||||
class="flex items-center gap-15 border border-(--border-light) dark:border-(--border-dark) p-5 rounded-md hover:bg-gray-50 hover:dark:bg-gray-700 bg-(--second-light) dark:bg-(--second-dark) dark:text-white text-black">
|
||||
<img :src="variant.image" :alt="variant.image" class="w-16 h-16 object-cover" />
|
||||
<p class="">{{ variant.name }}</p>
|
||||
<p class="">{{ variant.productNumber }}</p>
|
||||
<p class="">{{ variant.value }}</p>
|
||||
<p class="">{{ variant.price }}</p>
|
||||
<p class="">{{ variant.quantity }}</p>
|
||||
<button class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
|
||||
@click="addToCart(variant)">
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const variants = ref([
|
||||
{
|
||||
image: 'img',
|
||||
name: 'Duży fotelik narożny ',
|
||||
productNumber: 'NC209/7000',
|
||||
value: '20,000',
|
||||
price: '519,00 zł',
|
||||
quantity: 10
|
||||
},
|
||||
{
|
||||
image: 'img',
|
||||
name: 'Duży fotelik narożny ',
|
||||
productNumber: 'NC209/7000',
|
||||
value: '20,000',
|
||||
price: '519,00 zł',
|
||||
quantity: 5
|
||||
},
|
||||
{
|
||||
image: 'img',
|
||||
name: 'Duży fotelik narożny ',
|
||||
productNumber: 'NC209/7000',
|
||||
value: '20,000',
|
||||
price: '519,00 zł',
|
||||
quantity: 8
|
||||
}
|
||||
])
|
||||
|
||||
const addToCart = (variant: any) => {
|
||||
console.log('Added to cart:', variant)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user