Add AddressDetailBottomSheet with same UX as orders
All checks were successful
Build Docker Image / build (push) Successful in 4m21s
All checks were successful
Build Docker Image / build (push) Successful in 4m21s
- Panel slides left when address is selected - Bottom sheet slides up with address details - Shows location, map preview, edit/delete actions
This commit is contained in:
178
app/components/catalog/AddressDetailBottomSheet.vue
Normal file
178
app/components/catalog/AddressDetailBottomSheet.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="address-slide">
|
||||||
|
<div
|
||||||
|
v-if="isOpen && addressUuid"
|
||||||
|
class="fixed inset-x-0 bottom-0 z-50 flex flex-col"
|
||||||
|
style="height: 70vh"
|
||||||
|
>
|
||||||
|
<!-- Backdrop (clickable to close) -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 -top-[30vh] bg-black/30"
|
||||||
|
@click="emit('close')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Sheet content -->
|
||||||
|
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
||||||
|
<!-- Header with drag handle and close -->
|
||||||
|
<div class="sticky top-0 z-10 bg-black/30 backdrop-blur-md border-b border-white/10">
|
||||||
|
<div class="flex justify-center py-2">
|
||||||
|
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between px-6 pb-4">
|
||||||
|
<template v-if="address">
|
||||||
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div class="w-10 h-10 bg-emerald-500/20 rounded-xl flex items-center justify-center flex-shrink-0 text-2xl">
|
||||||
|
{{ isoToEmoji(address.countryCode) }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-bold text-white truncate">{{ address.name }}</div>
|
||||||
|
<div class="text-sm text-white/60 truncate">{{ address.address }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center gap-3 flex-1">
|
||||||
|
<div class="w-10 h-10 bg-white/10 rounded-xl animate-pulse" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="h-5 bg-white/10 rounded w-48 animate-pulse" />
|
||||||
|
<div class="h-4 bg-white/10 rounded w-32 mt-1 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button class="btn btn-ghost btn-sm btn-circle text-white/60 hover:text-white flex-shrink-0" @click="emit('close')">
|
||||||
|
<Icon name="lucide:x" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-if="address" class="overflow-y-auto h-[calc(70vh-100px)] px-6 py-4 space-y-4">
|
||||||
|
<!-- Location info -->
|
||||||
|
<div class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:map-pin" size="18" />
|
||||||
|
{{ t('profileAddresses.detail.location') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-start gap-2 text-white/80">
|
||||||
|
<Icon name="lucide:navigation" size="14" class="text-white/50 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{{ address.address }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="address.latitude && address.longitude" class="flex items-center gap-2 text-white/60">
|
||||||
|
<Icon name="lucide:crosshair" size="14" class="text-white/50" />
|
||||||
|
<span class="font-mono text-xs">{{ address.latitude.toFixed(6) }}, {{ address.longitude.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map preview -->
|
||||||
|
<div v-if="address.latitude && address.longitude" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<Icon name="lucide:map" size="18" />
|
||||||
|
{{ t('profileAddresses.detail.map') }}
|
||||||
|
</div>
|
||||||
|
<div class="h-48 rounded-lg overflow-hidden">
|
||||||
|
<ClientOnly>
|
||||||
|
<MapboxMap
|
||||||
|
:map-id="'address-preview-' + addressUuid"
|
||||||
|
style="width: 100%; height: 100%"
|
||||||
|
:options="{
|
||||||
|
style: 'mapbox://styles/mapbox/dark-v11',
|
||||||
|
center: [address.longitude, address.latitude],
|
||||||
|
zoom: 14,
|
||||||
|
interactive: false
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<MapboxDefaultMarker
|
||||||
|
:marker-id="'address-marker'"
|
||||||
|
:lnglat="[address.longitude, address.latitude]"
|
||||||
|
color="#10b981"
|
||||||
|
/>
|
||||||
|
</MapboxMap>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<NuxtLink :to="localePath(`/clientarea/addresses/${addressUuid}`)" class="flex-1">
|
||||||
|
<button class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20">
|
||||||
|
<Icon name="lucide:pencil" size="14" class="mr-2" />
|
||||||
|
{{ t('profileAddresses.actions.edit') }}
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm bg-error/20 border-error/30 text-error hover:bg-error/30"
|
||||||
|
@click="handleDelete"
|
||||||
|
:disabled="isDeleting"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:trash-2" size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-else class="px-6 py-4 space-y-4">
|
||||||
|
<div class="h-24 bg-white/5 rounded-xl animate-pulse" />
|
||||||
|
<div class="h-48 bg-white/5 rounded-xl animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean
|
||||||
|
addressUuid: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'close': []
|
||||||
|
'deleted': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
const { items, isoToEmoji, deleteAddress } = useTeamAddresses()
|
||||||
|
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
|
||||||
|
const address = computed(() => {
|
||||||
|
if (!props.addressUuid) return null
|
||||||
|
return items.value.find(a => a.uuid === props.addressUuid) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!props.addressUuid) return
|
||||||
|
isDeleting.value = true
|
||||||
|
const success = await deleteAddress(props.addressUuid)
|
||||||
|
isDeleting.value = false
|
||||||
|
if (success) {
|
||||||
|
emit('deleted')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.address-slide-enter-active,
|
||||||
|
.address-slide-leave-active {
|
||||||
|
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-slide-enter-from,
|
||||||
|
.address-slide-leave-to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-slide-enter-to,
|
||||||
|
.address-slide-leave-from {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,55 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<CatalogPage
|
<div>
|
||||||
:items="mapPoints"
|
<CatalogPage
|
||||||
:loading="isLoading"
|
:items="mapPoints"
|
||||||
:use-server-clustering="false"
|
:loading="isLoading"
|
||||||
map-id="addresses-map"
|
:use-server-clustering="false"
|
||||||
point-color="#10b981"
|
map-id="addresses-map"
|
||||||
:hovered-id="hoveredAddressId"
|
point-color="#10b981"
|
||||||
:show-panel="true"
|
:hovered-id="hoveredAddressId"
|
||||||
panel-width="w-96"
|
:show-panel="!selectedAddressId"
|
||||||
:hide-view-toggle="true"
|
panel-width="w-96"
|
||||||
@select="onMapSelect"
|
:hide-view-toggle="true"
|
||||||
@update:hovered-id="hoveredAddressId = $event"
|
@select="onMapSelect"
|
||||||
>
|
@update:hovered-id="hoveredAddressId = $event"
|
||||||
<template #panel>
|
>
|
||||||
<!-- Panel header -->
|
<template #panel>
|
||||||
<div class="p-4 border-b border-white/10 flex-shrink-0">
|
<!-- Panel header -->
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="p-4 border-b border-white/10 flex-shrink-0">
|
||||||
<span class="font-semibold">{{ t('cabinetNav.addresses') }}</span>
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="font-semibold">{{ t('cabinetNav.addresses') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="relative mb-3">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('common.search')"
|
||||||
|
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add button -->
|
||||||
|
<NuxtLink :to="localePath('/clientarea/addresses/new')">
|
||||||
|
<button class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20">
|
||||||
|
<Icon name="lucide:plus" size="14" class="mr-1" />
|
||||||
|
{{ t('profileAddresses.actions.add') }}
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Addresses list -->
|
||||||
<div class="relative mb-3">
|
<div class="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
<input
|
<template v-if="displayItems.length > 0">
|
||||||
v-model="searchQuery"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('common.search')"
|
|
||||||
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
|
|
||||||
/>
|
|
||||||
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add button -->
|
|
||||||
<NuxtLink :to="localePath('/clientarea/addresses/new')">
|
|
||||||
<button class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20">
|
|
||||||
<Icon name="lucide:plus" size="14" class="mr-1" />
|
|
||||||
{{ t('profileAddresses.actions.add') }}
|
|
||||||
</button>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Addresses list -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-3 space-y-2">
|
|
||||||
<template v-if="displayItems.length > 0">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="item in displayItems"
|
|
||||||
:key="item.uuid"
|
|
||||||
:to="localePath(`/clientarea/addresses/${item.uuid}`)"
|
|
||||||
class="block"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
|
v-for="item in displayItems"
|
||||||
|
:key="item.uuid"
|
||||||
class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
|
class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
|
||||||
|
:class="{ 'ring-2 ring-emerald-500': selectedAddressId === item.uuid }"
|
||||||
|
@click="selectedAddressId = item.uuid"
|
||||||
@mouseenter="hoveredAddressId = item.uuid"
|
@mouseenter="hoveredAddressId = item.uuid"
|
||||||
@mouseleave="hoveredAddressId = undefined"
|
@mouseleave="hoveredAddressId = undefined"
|
||||||
>
|
>
|
||||||
@@ -61,29 +60,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</template>
|
||||||
</template>
|
<template v-else>
|
||||||
<template v-else>
|
<div class="text-center py-8">
|
||||||
<div class="text-center py-8">
|
<div class="text-3xl mb-2">📍</div>
|
||||||
<div class="text-3xl mb-2">📍</div>
|
<div class="font-semibold text-sm mb-1">{{ t('profileAddresses.empty.title') }}</div>
|
||||||
<div class="font-semibold text-sm mb-1">{{ t('profileAddresses.empty.title') }}</div>
|
<div class="text-xs text-white/60 mb-3">{{ t('profileAddresses.empty.description') }}</div>
|
||||||
<div class="text-xs text-white/60 mb-3">{{ t('profileAddresses.empty.description') }}</div>
|
<NuxtLink :to="localePath('/clientarea/addresses/new')">
|
||||||
<NuxtLink :to="localePath('/clientarea/addresses/new')">
|
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
|
||||||
<button class="btn btn-sm bg-white/10 border-white/20 text-white hover:bg-white/20">
|
<Icon name="lucide:plus" size="14" class="mr-1" />
|
||||||
<Icon name="lucide:plus" size="14" class="mr-1" />
|
{{ t('profileAddresses.empty.cta') }}
|
||||||
{{ t('profileAddresses.empty.cta') }}
|
</button>
|
||||||
</button>
|
</NuxtLink>
|
||||||
</NuxtLink>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="p-3 border-t border-white/10 flex-shrink-0">
|
<div class="p-3 border-t border-white/10 flex-shrink-0">
|
||||||
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ items.length }}</span>
|
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ items.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</CatalogPage>
|
</CatalogPage>
|
||||||
|
|
||||||
|
<!-- Address Detail Bottom Sheet -->
|
||||||
|
<AddressDetailBottomSheet
|
||||||
|
:is-open="!!selectedAddressId"
|
||||||
|
:address-uuid="selectedAddressId"
|
||||||
|
@close="selectedAddressId = null"
|
||||||
|
@deleted="selectedAddressId = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -104,6 +111,7 @@ const {
|
|||||||
|
|
||||||
const hoveredAddressId = ref<string>()
|
const hoveredAddressId = ref<string>()
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const selectedAddressId = ref<string | null>(null)
|
||||||
|
|
||||||
// Map points
|
// Map points
|
||||||
const mapPoints = computed(() => {
|
const mapPoints = computed(() => {
|
||||||
@@ -130,7 +138,7 @@ const displayItems = computed(() => {
|
|||||||
|
|
||||||
const onMapSelect = (item: { uuid?: string | null }) => {
|
const onMapSelect = (item: { uuid?: string | null }) => {
|
||||||
if (item.uuid) {
|
if (item.uuid) {
|
||||||
navigateTo(localePath(`/clientarea/addresses/${item.uuid}`))
|
selectedAddressId.value = item.uuid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,15 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Add address",
|
"add": "Add address",
|
||||||
|
"edit": "Edit",
|
||||||
"confirm_delete": "Delete this address?",
|
"confirm_delete": "Delete this address?",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleting": "Deleting..."
|
"deleting": "Deleting..."
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"location": "Location",
|
||||||
|
"map": "Map"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "New address",
|
"title": "New address",
|
||||||
"title_edit": "Edit address",
|
"title_edit": "Edit address",
|
||||||
|
|||||||
@@ -5,10 +5,15 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"add": "Добавить адрес",
|
"add": "Добавить адрес",
|
||||||
|
"edit": "Редактировать",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"deleting": "Удаление...",
|
"deleting": "Удаление...",
|
||||||
"confirm_delete": "Удалить этот адрес?"
|
"confirm_delete": "Удалить этот адрес?"
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"location": "Местоположение",
|
||||||
|
"map": "Карта"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Новый адрес",
|
"title": "Новый адрес",
|
||||||
"title_edit": "Редактирование адреса",
|
"title_edit": "Редактирование адреса",
|
||||||
|
|||||||
Reference in New Issue
Block a user