Add AddressDetailBottomSheet with same UX as orders
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:
Ruslan Bakiev
2026-01-29 21:00:18 +07:00
parent 0a63d4b0b2
commit 8a2a804c58
4 changed files with 266 additions and 70 deletions

View 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>

View File

@@ -1,4 +1,5 @@
<template> <template>
<div>
<CatalogPage <CatalogPage
:items="mapPoints" :items="mapPoints"
:loading="isLoading" :loading="isLoading"
@@ -6,7 +7,7 @@
map-id="addresses-map" map-id="addresses-map"
point-color="#10b981" point-color="#10b981"
:hovered-id="hoveredAddressId" :hovered-id="hoveredAddressId"
:show-panel="true" :show-panel="!selectedAddressId"
panel-width="w-96" panel-width="w-96"
:hide-view-toggle="true" :hide-view-toggle="true"
@select="onMapSelect" @select="onMapSelect"
@@ -42,14 +43,12 @@
<!-- Addresses list --> <!-- Addresses list -->
<div class="flex-1 overflow-y-auto p-3 space-y-2"> <div class="flex-1 overflow-y-auto p-3 space-y-2">
<template v-if="displayItems.length > 0"> <template v-if="displayItems.length > 0">
<NuxtLink <div
v-for="item in displayItems" v-for="item in displayItems"
:key="item.uuid" :key="item.uuid"
:to="localePath(`/clientarea/addresses/${item.uuid}`)"
class="block"
>
<div
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,7 +60,6 @@
</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">
@@ -84,6 +82,15 @@
</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
} }
} }

View File

@@ -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",

View File

@@ -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": "Редактирование адреса",