карты пвз
All checks were successful
Deploy / build (push) Successful in 2m37s

This commit is contained in:
alsaze 2025-11-10 16:35:00 +03:00
parent 6cd7bd1dec
commit bff6833781
10 changed files with 372 additions and 100 deletions

146
components/DeliveryInfo.vue Normal file
View File

@ -0,0 +1,146 @@
<template>
<ul class="delivery-info">
<li v-if="showFullContent" class="delivery-info__item">
<Icon class="delivery-info__icon" name="lucide:truck" />
<span class="delivery-info__text">Yandex</span>
</li>
<li class="delivery-info__item">
<Icon class="delivery-info__icon" name="lucide:map-pin" />
<span class="delivery-info__text">{{ checkoutPickupPoint?.address?.full_address }}</span>
</li>
<li class="delivery-info__item">
<Icon class="delivery-info__icon" name="lucide:shirt" size="xl" />
<span class="delivery-info__text">{{ checkoutPickupPoint?.pickup_services?.is_fitting_allowed ? 'с примеркой' : 'без примерки' }}</span>
</li>
<li class="delivery-info__item">
<Icon class="delivery-info__icon" name="lucide:package" />
<span class="delivery-info__text">срок хранения 7 дней</span>
</li>
<li class="delivery-info__item">
<UAccordion
:items="availableDays"
:ui="{
body: 'py-0',
panel: 'p-0',
wrapper: 'space-y-0',
}"
>
<template #schedule="{ item }">
<div>
<div
v-for="(scheduleItem, index) in item.content"
:key="index"
>
<Icon class="delivery-info__icon mr-2" name="lucide:clock" />
<span class="mr-2">{{ scheduleItem.dayName }}</span>
<span>{{ scheduleItem.timeRange }}</span>
</div>
</div>
</template>
</UAccordion>
</li>
<li>
<UAccordion :items="details" />
</li>
<UButton
v-if="showFullContent"
class="justify-content-center"
label="Привезти сюда"
size="xl"
@click="nextStep"
/>
</ul>
</template>
<script setup lang="ts">
import type { AccordionItem } from '@nuxt/ui'
import { computed, ref } from 'vue'
import { useCheckout } from '~/composables/useCheckout'
withDefaults(defineProps<{
showFullContent?: boolean
}>(), {
showFullContent: true,
})
const { checkoutPickupPoint, nextStep } = useCheckout()
const restrictionDays = {
1: 'пн',
2: 'вт',
3: 'ср',
4: 'чт',
5: 'пт',
6: 'сб',
7: 'вс',
}
// Функция для форматирования времени с ведущим нулем
const formatTime = (time: { hours: number | string, minutes: number | string }): string => {
const hours = String(time.hours).padStart(2, '0')
const minutes = String(time.minutes).padStart(2, '0')
return `${hours}:${minutes}`
}
// Функция для форматирования временного диапазона
const formatTimeRange = (timeFrom: { hours: number | string, minutes: number | string }, timeTo: { hours: number | string, minutes: number | string }): string => {
return `${formatTime(timeFrom)} - ${formatTime(timeTo)}`
}
// Аккордеон с расписанием - используем слот вместо HTML строки
const availableDays = computed(() => {
const scheduleItems = checkoutPickupPoint?.value?.schedule?.restrictions?.map((restriction) => {
const dayName = restrictionDays[restriction.days[0]]
const timeRange = formatTimeRange(restriction.time_from, restriction.time_to)
return { dayName, timeRange }
}) || []
return [
{
label: 'График работы',
icon: 'i-lucide:calendar',
slot: 'schedule',
content: scheduleItems,
},
]
})
const details = ref<AccordionItem[]>([
{
label: 'Подробнее',
icon: 'i-lucide:info',
content: checkoutPickupPoint?.value?.address?.comment || 'Дополнительная информация о пункте выдачи',
},
])
</script>
<style lang="scss">
.delivery-info {
display: flex;
flex-direction: column;
gap: 8px;
&__item {
display: flex;
align-items: flex-start;
gap: 6px;
}
&__icon {
flex-shrink: 0;
width: 20px;
height: 20px;
}
&__text {
flex: 1;
line-height: 1.4;
}
}
</style>

View File

@ -20,11 +20,11 @@
@true-bounds="trueBounds = $event"
>
<YandexMapMarker
v-for="marker in pickupPoints"
:key="marker.id"
v-for="pickupPoint in pickupPoints"
:key="pickupPoint.id"
position="top-center left-center"
:settings="{ coordinates: [marker.position.longitude, marker.position.latitude] }"
@click="centerMap([marker.position.longitude, marker.position.latitude])"
:settings="{ coordinates: [pickupPoint.position.longitude, pickupPoint.position.latitude] }"
@click="centerMap(pickupPoint)"
>
<div class="marker">
<Icon name="i-lucide-map-pin" class="marker__icon" />
@ -41,9 +41,10 @@
</template>
<script setup lang="ts">
import type { LngLat, LngLatBounds, YMap } from '@yandex/ymaps3-types'
import type { LngLatBounds, YMap } from '@yandex/ymaps3-types'
import type { YMapLocationRequest } from '@yandex/ymaps3-types/imperative/YMap'
import type { YMapClusterer } from '@yandex/ymaps3-types/packages/clusterer'
import type { PickupPoint } from '~/server/shared/types/yandex_pvz'
import { useGeolocation } from '@vueuse/core'
import { computed, shallowRef } from 'vue'
import {
@ -53,15 +54,11 @@ import {
YandexMapDefaultSchemeLayer,
YandexMapMarker,
} from 'vue-yandex-maps'
import { useCheckout } from '~/composables/useCheckout'
defineProps<{
pickupPoints: {
id: string
address: string
position: { latitude: number, longitude: number }
}[]
}>()
defineProps<{ pickupPoints: PickupPoint[] }>()
const { setCheckoutPickupPoint, isPickupPointSelected } = useCheckout()
const { coords } = useGeolocation()
const clusterer = shallowRef<YMapClusterer | null>(null)
const trueBounds = ref<LngLatBounds>([[0, 0], [0, 0]])
@ -73,12 +70,15 @@ const location = ref<YMapLocationRequest>({
zoom: 2,
})
function centerMap(coordinates: LngLat) {
function centerMap(pickupPoint: PickupPoint) {
location.value = {
center: coordinates,
center: [pickupPoint.position.longitude, pickupPoint.position.latitude],
zoom: 18,
duration: 2500,
duration: 500,
}
setCheckoutPickupPoint(pickupPoint)
isPickupPointSelected.value = true
}
watch(coords, (newCoords) => {

View File

@ -1,13 +1,32 @@
import type { PickupPoint } from '~/server/shared/types/yandex_pvz'
import { createSharedComposable, useStorage } from '@vueuse/core'
export const useCheckout = createSharedComposable(() => {
const router = useRouter()
const route = useRoute()
const contacts = useStorage('checkout-contacts', { name: '', surname: '', phone: '', email: '' })
const isPickupPointSelected = ref(false)
const setContacts = (data: { name: string, surname: string, phone: string, email: string }) => {
contacts.value = data
const checkoutPickupPoint = useStorage<PickupPoint | undefined>(
'checkout-pickupPoint',
undefined,
undefined,
{
serializer: {
read: (v: string) => v ? JSON.parse(v) : undefined,
write: (v: PickupPoint | undefined) => JSON.stringify(v),
},
},
)
const setCheckoutPickupPoint = (point: PickupPoint | undefined) => {
checkoutPickupPoint.value = point
}
const checkoutContacts = useStorage('checkout-contacts', { name: '', surname: '', phone: '', email: '' })
const setCheckoutContacts = (data: { name: string, surname: string, phone: string, email: string }) => {
checkoutContacts.value = data
}
const checkoutSteps = [
@ -25,9 +44,16 @@ export const useCheckout = createSharedComposable(() => {
},
]
const currentCheckoutStep = ref(checkoutSteps.find(value => value.title === route.path.split('/').pop()) || checkoutSteps[0])
const currentCheckoutStep
= ref(checkoutSteps.find(value => value.title === route.path.split('/').pop()) || checkoutSteps[0])
function previewStep() {
if (isPickupPointSelected.value) {
isPickupPointSelected.value = false
setCheckoutPickupPoint(undefined)
return
}
const findIndex = checkoutSteps.findIndex(value => value.step === currentCheckoutStep.value.step)
if (findIndex !== 0) {
currentCheckoutStep.value = checkoutSteps[findIndex - 1]
@ -52,8 +78,12 @@ export const useCheckout = createSharedComposable(() => {
}
return {
contacts,
setContacts,
isPickupPointSelected,
checkoutPickupPoint,
setCheckoutPickupPoint,
checkoutContacts,
setCheckoutContacts,
checkoutSteps,
currentCheckoutStep,

View File

@ -9,8 +9,6 @@
&nbsp;&bull;&nbsp;
{{ t(`checkoutSteps.${currentCheckoutStep?.title}`) }}
</h3>
<Icon class="cursor-pointer w-6 h-6" name="lucide:arrow-right" @click="nextStep" />
</div>
</header>
@ -25,7 +23,7 @@
<script setup lang="ts">
import { useCheckout } from '../composables/useCheckout'
const { previewStep, nextStep, currentCheckoutStep, checkoutSteps } = useCheckout()
const { previewStep, currentCheckoutStep, checkoutSteps } = useCheckout()
const { t } = useI18n()
</script>
@ -56,10 +54,13 @@ const { t } = useI18n()
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
text-align: center;
padding: 0 16px;
}
h3 {
flex: auto;
}
}
.main {

View File

@ -19,7 +19,7 @@ import PayBlock from '../components/PayBlock.vue'
const route = useRoute()
const { cart } = useCart()
const { contacts } = useCheckout()
const { checkoutContacts, checkoutPickupPoint } = useCheckout()
onMounted(async () => {
if (!route?.query?.ID)
@ -32,12 +32,13 @@ onMounted(async () => {
payment_method_title: 'Оплата по реквизитам',
set_paid: true,
billing: {
first_name: contacts?.value?.name,
last_name: contacts?.value?.surname,
phone: contacts?.value?.phone,
email: contacts?.value?.email,
address_1: 'ул. Ленина, 1',
city: 'Москва',
first_name: checkoutContacts?.value?.name,
last_name: checkoutContacts?.value?.surname,
phone: checkoutContacts?.value?.phone,
email: checkoutContacts?.value?.email,
address_1: checkoutPickupPoint.value?.address?.full_address,
postcode: checkoutPickupPoint.value?.address?.postal_code,
city: checkoutPickupPoint?.value?.address?.locality,
country: 'RU',
},
transaction_id: route?.query?.ID,

View File

@ -140,14 +140,14 @@
<script lang="ts" setup>
import { useCheckout } from '../../composables/useCheckout'
const { contacts, setContacts, nextStep } = useCheckout()
const { checkoutContacts, setCheckoutContacts, nextStep } = useCheckout()
const { errors, handleSubmit, defineField } = useForm({
initialValues: {
name: contacts.value.name,
surname: contacts.value.surname,
phone: contacts.value.phone,
email: contacts.value.email,
name: checkoutContacts.value.name,
surname: checkoutContacts.value.surname,
phone: checkoutContacts.value.phone,
email: checkoutContacts.value.email,
},
validationSchema: {
name(value: string) {
@ -189,7 +189,7 @@ const [phone, phoneAttrs] = defineField('phone')
const [email, emailAttrs] = defineField('email')
const onSubmit = handleSubmit((values) => {
setContacts(values)
setCheckoutContacts(values)
nextStep()
})

View File

@ -1,34 +1,62 @@
<template>
<div v-if="coords" class="delivery">
<div class="delivery__sidebar">
<UInput v-model="searchTerm" size="xl" class="w-full" placeholder="pvz" />
<DeliveryInfo v-if="isPickupPointSelected" />
<div class="delivery__items">
<div
v-for="point in filteredPvz?.points"
:key="point.id"
class="pickup-point-item"
@click="centerMap(point)"
<div v-else class="delivery__search">
<UInput
v-model="searchTerm"
size="xl"
class="w-full"
placeholder="Выберите пункт выдачи"
:ui="{ trailing: 'pe-1' }"
>
<h3>Yandex</h3>
{{ `${point?.address?.street} ${point?.address?.house}` }}
<Icon class="pickup-point-item__action" name="lucide:chevron-right" />
<template v-if="searchTerm?.length" #trailing>
<UButton
color="neutral"
variant="link"
size="sm"
icon="i-lucide-circle-x"
aria-label="Clear input"
@click="searchTerm = ''"
/>
</template>
</UInput>
<div class="delivery__items">
<div
v-for="pickupPoint in filteredPoints"
:key="pickupPoint.id"
class="pickup-point-card"
@click="centerMap(pickupPoint)"
>
<div class="pickup-point-card__content">
<h3>Yandex</h3>
<p>{{ `${pickupPoint?.address?.street} ${pickupPoint?.address?.house}` }}</p>
<h3>с day month бесплатно</h3>
</div>
<Icon class="pickup-point-card__action" name="lucide:chevron-right" />
</div>
</div>
</div>
</div>
<PvzMap ref="mapRef" :pickup-points="yandexPvz?.points" />
<PvzMap ref="mapRef" :pickup-points="filteredPoints" />
</div>
</template>
<script setup lang="ts">
import type { PickupPoint, YandexPvzResponse } from '~/server/shared/types/yandex_pvz'
import { useGeolocation } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import DeliveryInfo from '~/components/DeliveryInfo.vue'
import PvzMap from '~/components/PvzMap.vue'
import { useCheckout } from '~/composables/useCheckout'
const { isPickupPointSelected } = useCheckout()
const { coords } = useGeolocation()
const mapRef = ref<InstanceType<typeof PvzMap> | null>(null)
const yandexPvz = ref('')
const yandexPvz = ref<YandexPvzResponse>()
const searchTerm = ref('')
const waitForCoords = () =>
@ -59,7 +87,7 @@ onMounted(async () => {
})
// получения пунктов выдачи города из geo_id
const { data: yandexPvzApi } = await useFetch('/api/yandex_pvz', {
const { data: yandexPvzApi } = await useFetch<YandexPvzResponse>('/api/yandex_pvz', {
method: 'POST',
body: {
geo_id: yandexLocation?.value?.variants[0]?.geo_id,
@ -70,18 +98,18 @@ onMounted(async () => {
})
// TODO сделать отдельный компонент UISearch
const filteredPvz = computed(() => {
if (!searchTerm.value && searchTerm.value === '')
return yandexPvz.value
const result = yandexPvz?.value?.points?.filter(value => value?.address?.full_address?.toLowerCase().includes(searchTerm.value.toLowerCase()))
return {
points: [...result],
const filteredPoints = computed<PickupPoint[]>(() => {
if (!searchTerm.value || searchTerm.value === '') {
return yandexPvz.value?.points || []
}
return yandexPvz.value?.points?.filter(point =>
point?.address?.full_address?.toLowerCase().includes(searchTerm.value.toLowerCase()),
) || []
})
const centerMap = (point: any) => {
mapRef.value?.centerMap([point?.position?.longitude, point?.position?.latitude])
mapRef.value?.centerMap(point)
}
definePageMeta({
@ -90,36 +118,49 @@ definePageMeta({
</script>
<style lang="scss">
@use '~/assets/scss/utils' as *;
.delivery {
display: flex;
flex-direction: row;
&__sidebar {
flex-shrink: 0;
max-height: calc(100vh - 40px);
overflow-y: auto;
width: 410px;
padding: 24px;
padding-inline: 24px;
}
&__items {
height: calc(100dvh - 54px - 40px - 24px);
overflow-y: auto;
flex-shrink: 0;
height: calc(100dvh - 54px - 40px - 24px);
}
}
.pickup-point-item {
.pickup-point-card {
position: relative;
width: 400px;
height: 100px;
padding-block: 24px;
width: 100%;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 16px;
cursor: pointer;
&__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
&__action {
position: absolute;
right: 20px;
top: 40px;
flex-shrink: 0;
width: 16px;
height: 16px;
color: #999;
}
}
</style>

View File

@ -25,30 +25,7 @@
/>
</h3>
<div class="flex items-center gap-2">
<UIcon name="i-ph-user" class="text-muted-foreground" />
<span>{{ contacts?.name }} {{ contacts?.surname }}</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-ph-user" class="text-muted-foreground" />
<span>{{ contacts?.name }} {{ contacts?.surname }}</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-ph-user" class="text-muted-foreground" />
<span>{{ contacts?.name }} {{ contacts?.surname }}</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-ph-user" class="text-muted-foreground" />
<span>{{ contacts?.name }} {{ contacts?.surname }}</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-ph-user" class="text-muted-foreground" />
<span>{{ contacts?.name }} {{ contacts?.surname }}</span>
</div>
<DeliveryInfo :show-full-content="false" />
</div>
<div class="space-y-2">
@ -66,17 +43,17 @@
<div class="flex items-center gap-2">
<UIcon name="i-ph-user" class="text-muted-foreground" />
<span>{{ contacts?.name }} {{ contacts?.surname }}</span>
<span>{{ checkoutContacts?.name }} {{ checkoutContacts?.surname }}</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-ph-envelope-simple" class="text-muted-foreground" />
<span>{{ contacts?.email }}</span>
<span>{{ checkoutContacts?.email }}</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-ph-phone" class="text-muted-foreground" />
<span>{{ contacts?.phone }}</span>
<span>{{ checkoutContacts?.phone }}</span>
</div>
</div>
</div>
@ -90,7 +67,7 @@ import SummaryCartItem from '../../components/cart/SummaryCartItem.vue'
import PayBlock from '../../components/PayBlock.vue'
const { cart } = useCart()
const { contacts, setStep } = useCheckout()
const { checkoutContacts, setStep } = useCheckout()
definePageMeta({
layout: 'checkout',

View File

@ -1,13 +1,14 @@
import type { YandexPvzResponse } from '~/server/shared/types/yandex_pvz'
import axios from 'axios'
import { defineEventHandler, readBody } from 'h3'
export default defineEventHandler(async (event) => {
export default defineEventHandler(async (event): Promise<YandexPvzResponse | { error: string }> => {
try {
const data = await readBody(event)
const apiUrl = process.env.VITE_YANDEX_B2B_BASE_URL!
const token = process.env.VITE_YANDEX_B2B_TOKEN!
const response = await axios.post(
const response = await axios.post<YandexPvzResponse>(
`${apiUrl}/pickup-points/list`,
{
geo_id: data?.geo_id,

View File

@ -0,0 +1,75 @@
export interface GeoPosition {
latitude: number
longitude: number
}
export interface PvzAddress {
apartment: string
building: string
comment: string
country: string
full_address: string
geoId: number
house: string
housing: string
locality: string
postal_code: string
region: string
street: string
subRegion: string
}
export interface Contact {
phone: string
}
export interface TimeObject {
hours: number
minutes: number
}
export interface ScheduleRestriction {
days: number[]
time_from: TimeObject
time_to: TimeObject
}
export interface Schedule {
time_zone: number
restrictions: ScheduleRestriction[]
}
export interface PickupServices {
is_fitting_allowed: boolean
is_partial_refuse_allowed: boolean
is_paperless_pickup_allowed: boolean
is_unboxing_allowed: boolean
}
export interface PickupPoint {
id: string
operator_station_id: string
operator_id: string
name: string
type: 'pickup_point'
address: PvzAddress
position: GeoPosition
instruction: string
available_for_dropoff: boolean
available_for_c2c_dropoff: boolean
contact: Contact
schedule: Schedule
pickup_services: PickupServices
payment_methods: string[]
is_dark_store: boolean
is_market_partner: boolean
is_post_office: boolean
is_yandex_branded: boolean
dayoffs: any[]
deactivation_date: string | null
deactivation_date_predicted_debt: string | null
}
export interface YandexPvzResponse extends PickupPoint {
points: PickupPoint[]
}