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

View File

@ -1,13 +1,32 @@
import type { PickupPoint } from '~/server/shared/types/yandex_pvz'
import { createSharedComposable, useStorage } from '@vueuse/core' import { createSharedComposable, useStorage } from '@vueuse/core'
export const useCheckout = createSharedComposable(() => { export const useCheckout = createSharedComposable(() => {
const router = useRouter() const router = useRouter()
const route = useRoute() 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 }) => { const checkoutPickupPoint = useStorage<PickupPoint | undefined>(
contacts.value = data '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 = [ 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() { function previewStep() {
if (isPickupPointSelected.value) {
isPickupPointSelected.value = false
setCheckoutPickupPoint(undefined)
return
}
const findIndex = checkoutSteps.findIndex(value => value.step === currentCheckoutStep.value.step) const findIndex = checkoutSteps.findIndex(value => value.step === currentCheckoutStep.value.step)
if (findIndex !== 0) { if (findIndex !== 0) {
currentCheckoutStep.value = checkoutSteps[findIndex - 1] currentCheckoutStep.value = checkoutSteps[findIndex - 1]
@ -52,8 +78,12 @@ export const useCheckout = createSharedComposable(() => {
} }
return { return {
contacts, isPickupPointSelected,
setContacts, checkoutPickupPoint,
setCheckoutPickupPoint,
checkoutContacts,
setCheckoutContacts,
checkoutSteps, checkoutSteps,
currentCheckoutStep, currentCheckoutStep,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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