карта ПВЗ
All checks were successful
Deploy / build (push) Successful in 12m45s

This commit is contained in:
alsaze 2025-10-22 22:57:29 +03:00
parent 3119ecc2fa
commit 8e68d5b162
14 changed files with 314 additions and 115 deletions

View File

@ -11,11 +11,17 @@ WORKDIR /app
COPY --from=build /app/.output/ ./ COPY --from=build /app/.output/ ./
ENV PORT=80 ENV PORT=80
ENV BSPB_MERCHANT_ID=TT00001 ENV BSPB_MERCHANT_ID=TT00001
ENV BSPB_MERCHANT_PASSWORD=ztTGre1OBZg3 ENV BSPB_MERCHANT_PASSWORD=ztTGre1OBZg3
ENV BSPB_API_URL=https://pgtest.bspb.ru:5443 ENV BSPB_API_URL=https://pgtest.bspb.ru:5443
ENV VITE_BASE_URL=https://paxton.koptilnya.xyz ENV VITE_YANDEX_MAPS_TOKEN=13f4c06b-cb7e-4eeb-81f1-af52f12587b2
ENV VITE_YANDEX_B2B_BASE_URL=https://b2b-authproxy.taxi.yandex.net/api/b2b/platform
ENV VITE_YANDEX_B2B_TOKEN=y0__xCqxdamBhjVmRUgrIqL1BRb-Au03gwwIZjInV31ftl35o5nNA
ENV VITE_BASE_URL=http://localhost:3000
EXPOSE 80 EXPOSE 80

View File

@ -4,7 +4,7 @@
<template #body> <template #body>
<div>товары {{ `(${cart?.line_items?.length} шт)` }}</div> <div>товары {{ `(${cart?.line_items?.length} шт)` }}</div>
<div v-if="cartSum"> <div v-if="cartSum">
итого {{ cartSum }} <ProductPrice :is-headline="false" text="итого" :price="cartSum" />
</div> </div>
</template> </template>

View File

@ -1,6 +1,12 @@
<template> <template>
<div class="product-price"> <div class="product-price">
<h2>{{ price }} <Icon name="ph:currency-rub" /></h2> <h2 v-if="isHeadline" class="product-price__text">
{{ text }} {{ price }} <Icon name="ph:currency-rub" />
</h2>
<div v-else class="product-price__text">
{{ text }} {{ price }} <Icon name="ph:currency-rub" />
</div>
</div> </div>
</template> </template>
@ -10,12 +16,20 @@ defineProps({
type: [String, Number], type: [String, Number],
required: true, required: true,
}, },
isHeadline: {
type: Boolean,
default: true,
},
text: {
type: String,
default: '',
},
}) })
</script> </script>
<style lang="scss"> <style lang="scss">
.product-price { .product-price {
h2 { &__text {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 4px; gap: 4px;

View File

@ -1,13 +1,61 @@
<template> <template>
<div ref="mapContainer" class="w-full" style="height: calc(100dvh - 54px)" /> <div v-if="!hasCoords" class="p-4 text-center">
<div v-if="!coordsReady" class="p-4 text-center">
Определяем ваше местоположение... Определяем ваше местоположение...
</div> </div>
<YandexMap
v-else
v-model="map"
:settings="{
location: {
center: [coords?.longitude, coords?.latitude],
zoom: 9,
},
}"
class="w-full"
style="height: calc(100dvh - 54px)"
>
<YandexMapDefaultSchemeLayer />
<YandexMapDefaultFeaturesLayer />
<YandexMapClusterer
v-model="clusterer"
:grid-size="64"
zoom-on-cluster-click
@true-bounds="trueBounds = $event"
>
<YandexMapMarker
v-for="marker in pickupPoints"
:key="marker.id"
position="top-center left-center"
:settings="{ coordinates: [marker.position.longitude, marker.position.latitude] }"
>
<div class="marker">
<Icon name="i-lucide-map-pin" class="marker__icon" />
</div>
</YandexMapMarker>
<template #cluster="{ length }">
<div class="cluster fade-in">
{{ length }}
</div>
</template>
</YandexMapClusterer>
</YandexMap>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { LngLatBounds, YMap } from '@yandex/ymaps3-types'
import type { YMapClusterer } from '@yandex/ymaps3-types/packages/clusterer'
import { useGeolocation } from '@vueuse/core' import { useGeolocation } from '@vueuse/core'
import { ref, watch } from 'vue' import { computed, shallowRef } from 'vue'
import {
YandexMap,
YandexMapClusterer,
YandexMapDefaultFeaturesLayer,
YandexMapDefaultSchemeLayer,
YandexMapMarker,
} from 'vue-yandex-maps'
const props = defineProps<{ const props = defineProps<{
pickupPoints: { pickupPoints: {
@ -17,61 +65,79 @@ const props = defineProps<{
}[] }[]
}>() }>()
const token = '13f4c06b-cb7e-4eeb-81f1-af52f12587b2'
const mapContainer = ref<HTMLDivElement | null>(null)
const { coords } = useGeolocation() const { coords } = useGeolocation()
const coordsReady = ref(false)
const mapInstance = ref<any | null>(null)
const initMap = async (lat: number, lon: number) => { const hasCoords = computed(() => coords.value?.latitude !== Infinity && coords.value?.longitude !== Infinity)
if (!window.ymaps) {
await new Promise<void>((resolve) => { const map = shallowRef<null | YMap>(null)
const script = document.createElement('script')
script.src = `https://api-maps.yandex.ru/2.1/?apikey=${token}&lang=ru_RU` const clusterer = shallowRef<YMapClusterer | null>(null)
script.onload = () => resolve() const trueBounds = ref<LngLatBounds>([[0, 0], [0, 0]])
document.head.appendChild(script) </script>
})
<style lang="scss" scoped>
.marker {
position: relative;
background-color: #0f172b;
border: 1px solid #0f172b;
border-radius: 12px;
padding: 6px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transition:
transform 0.2s ease,
background-color 0.3s ease;
&:hover {
transform: scale(1.1);
background-color: #1e293b;
} }
window.ymaps.ready(() => { &::after {
const map = new window.ymaps.Map(mapContainer.value!, { content: '';
center: [lat, lon], position: absolute;
zoom: 10, bottom: -6px;
controls: ['zoomControl'], left: 50%;
}) transform: translateX(-50%);
mapInstance.value = map width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid #0f172b;
filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.15));
}
props?.pickupPoints?.forEach((point) => { &__icon {
const placemark = new window.ymaps.Placemark( color: white;
[point?.position?.latitude, point?.position?.longitude], width: 20px;
{ height: 20px;
balloonContent: `<strong>${Object.values(point?.position)}</strong>`, }
hintContent: Object.values(point?.position),
},
{ preset: 'islands#redIcon' },
)
map.geoObjects.add(placemark)
})
})
} }
const centerMap = (lat: number, lon: number) => { .cluster {
if (!mapInstance.value) background-color: #0f172b;
return color: white;
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
border: 2px solid #fff;
transition:
transform 0.2s ease,
background-color 0.3s ease;
mapInstance.value.setCenter([lat, lon], 18) &:hover {
transform: scale(1.1);
background-color: #1e293b;
}
} }
</style>
defineExpose({ centerMap })
watch(
() => coords.value,
async (val) => {
if (val.latitude && val.longitude && !coordsReady.value) {
coordsReady.value = true
await initMap(val.latitude, val.longitude)
}
},
{ deep: true },
)
</script>

View File

@ -15,7 +15,11 @@ export default defineNuxtConfig({
autoImports: true, autoImports: true,
}, },
], ],
'vue-yandex-maps/nuxt',
], ],
yandexMaps: {
apikey: process.env.VITE_YANDEX_MAPS_TOKEN,
},
css: ['~/assets/css/main.css', '~/assets/scss/main.scss'], css: ['~/assets/css/main.css', '~/assets/scss/main.scss'],
i18n: { i18n: {
locales: [ locales: [

View File

@ -30,12 +30,12 @@
"swiper": "^12.0.2", "swiper": "^12.0.2",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vue": "^3.5.17", "vue": "^3.5.17",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"vue-yandex-maps": "^2.2.3"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^4.13.2", "@antfu/eslint-config": "^4.13.2",
"@nuxt/devtools": "latest", "@nuxt/devtools": "latest",
"@yandex/ymaps3-types": "^1.0.17734864",
"eslint": "^9.27.0", "eslint": "^9.27.0",
"eslint-plugin-format": "^1.0.1", "eslint-plugin-format": "^1.0.1",
"sass": "^1.71.0", "sass": "^1.71.0",

View File

@ -15,7 +15,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCart } from '~/composables' import { useCart } from '~/composables'
import PayBlock from './checkout/PayBlock.vue' import PayBlock from '../components/PayBlock.vue'
const route = useRoute() const route = useRoute()
const { cart } = useCart() const { cart } = useCart()

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="delivery"> <div v-if="coords" class="delivery">
<div class="delivery__sidebar"> <div class="delivery__sidebar">
<div <div
v-for="point in data?.points" v-for="point in yandexPvz?.points"
:key="point.id" :key="point.id"
class="pickup-point-item" class="pickup-point-item"
@click="onPickupClick(point)" @click="onPickupClick(point)"
@ -12,15 +12,53 @@
<Icon class="pickup-point-item__action" name="lucide:chevron-right" /> <Icon class="pickup-point-item__action" name="lucide:chevron-right" />
</div> </div>
</div> </div>
<PvzMap ref="mapRef" :pickup-points="data?.points" /> <PvzMap ref="mapRef" :pickup-points="yandexPvz?.points" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { useGeolocation } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import PvzMap from '~/components/PvzMap.vue' import PvzMap from '~/components/PvzMap.vue'
const { data } = useFetch('/api/yandex') const yandexPvz = ref('')
const { coords } = useGeolocation()
const city = ref('')
const waitForCoords = () =>
new Promise<void>((resolve) => {
const stop = watch(coords, (newCoords) => {
if (newCoords.latitude && newCoords.longitude) {
stop()
resolve()
}
})
})
onMounted(async () => {
await waitForCoords()
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${coords.value.latitude}&lon=${coords.value.longitude}&format=json&accept-language=ru`,
)
const openstreetmap = await response.json()
const { data: yandexLocation } = await useFetch('/api/yandex_location', {
method: 'POST',
body: {
location: openstreetmap?.address?.city,
},
})
const { data: yandexPvzApi } = await useFetch('/api/yandex_pvz', {
method: 'POST',
body: {
geo_id: yandexLocation?.value?.variants[0]?.geo_id,
},
})
yandexPvz.value = yandexPvzApi.value
})
const mapRef = ref<InstanceType<typeof PvzMap> | null>(null) const mapRef = ref<InstanceType<typeof PvzMap> | null>(null)

View File

@ -87,7 +87,7 @@
<script setup lang="ts"> <script setup lang="ts">
import SummaryCartItem from '../../components/cart/SummaryCartItem.vue' import SummaryCartItem from '../../components/cart/SummaryCartItem.vue'
import PayBlock from './PayBlock.vue' import PayBlock from '../../components/PayBlock.vue'
const { cart } = useCart() const { cart } = useCart()
const { contacts, setStep } = useCheckout() const { contacts, setStep } = useCheckout()

View File

@ -1,27 +0,0 @@
import axios from 'axios'
import { defineEventHandler } from 'h3'
export default defineEventHandler(async () => {
const businessId = '216467845'
const token = 'ACMA:jSy96waKHy8jbq5NqjlGy77WarNYKOYO8aEgjExw:b9ff0949'
try {
const response = await axios.post(
`https://api.partner.market.yandex.ru/v2/businesses/${businessId}/logistics-points`,
{},
{
headers: {
'Content-Type': 'application/json',
'Accept-Language': 'ru-RU',
'Authorization': `Bearer ${token}`,
},
},
)
return response.data
}
catch (error) {
console.error('Ошибка при запросе к Яндекс API:', error)
return { error: 'Не удалось получить точки ПВЗ' }
}
})

View File

@ -0,0 +1,30 @@
import axios from 'axios'
import { defineEventHandler, readBody } from 'h3'
export default defineEventHandler(async (event) => {
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(
`${apiUrl}/location/detect`,
{
location: data?.location,
},
{
headers: {
'Content-Type': 'application/json',
'Accept-Language': 'ru-RU',
'Authorization': `Bearer ${token}`,
},
},
)
return response.data
}
catch (error) {
console.error('Ошибка при запросе к Яндекс API:', error)
return { error: `Не удалось получить координаты: ${error}` }
}
})

31
server/api/yandex_pvz.ts Normal file
View File

@ -0,0 +1,31 @@
import axios from 'axios'
import { defineEventHandler, readBody } from 'h3'
export default defineEventHandler(async (event) => {
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(
`${apiUrl}/pickup-points/list`,
{
geo_id: data?.geo_id,
type: 'pickup_point',
},
{
headers: {
'Content-Type': 'application/json',
'Accept-Language': 'ru-RU',
'Authorization': `Bearer ${token}`,
},
},
)
return response.data
}
catch (error) {
console.error('Ошибка при запросе к Яндекс API:', error)
return { error: `Не удалось получить точки ПВЗ: ${error}` }
}
})

View File

@ -1,23 +1,4 @@
{ {
// https://nuxt.com/docs/guide/concepts/typescript // https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json", "extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"target": "es2015",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"esModuleInterop": true,
"moduleResolution": "node",
"typeRoots": [
"./node_modules/@types",
"./node_modules/@yandex/ymaps3-types"
],
"paths": {
"ymaps3": [
"./node_modules/@yandex/ymaps3-types"
]
}
}
} }

View File

@ -2898,10 +2898,47 @@
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.9.0.tgz#7168b4ed647e625b05eb4e7e80fe8aabd00e3923" resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.9.0.tgz#7168b4ed647e625b05eb4e7e80fe8aabd00e3923"
integrity sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g== integrity sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==
"@yandex/ymaps3-types@^1.0.17734864": "@yandex/ymaps3-context-menu@>=0.0.2":
version "1.0.17734864" version "0.0.2"
resolved "https://registry.yarnpkg.com/@yandex/ymaps3-types/-/ymaps3-types-1.0.17734864.tgz#d5610c6f82d723b816f1bf6512885ddeb36ae151" resolved "https://registry.yarnpkg.com/@yandex/ymaps3-context-menu/-/ymaps3-context-menu-0.0.2.tgz#c5b33f042713100b48337796b96192f9631318ad"
integrity sha512-RG8KKtLJ7cprdkPDKQ3SZg5lPcw3Y6/Y5oD2YKAe2zIvzq+iIakL/HQoA9ZHlDqnLMWcfHJbdAHKEUuTq+QXSA== integrity sha512-i0/ALd6pzVssvzczgcRJH5SzL3dnehHRwFAwrRdVj/xdb5c5C6VTolr+HhBBh0BCbS+p+fOEVoAEIAibynyYfQ==
"@yandex/ymaps3-default-ui-theme@>=0.0.19":
version "0.0.19"
resolved "https://registry.yarnpkg.com/@yandex/ymaps3-default-ui-theme/-/ymaps3-default-ui-theme-0.0.19.tgz#f13b7234c3c7841eb0fa057bc7fda905ddd27e4e"
integrity sha512-kjrlmrUQ9OnULbSnZ+AkwHeN/QG3B6rEUcA8MXs3ai33inyWU3n+ivsW2Ezbcw5e6+QiG4h5wg1c32qZJWI+ig==
"@yandex/ymaps3-drawer-control@>=0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@yandex/ymaps3-drawer-control/-/ymaps3-drawer-control-0.0.2.tgz#ff8998fe1e6b91fb031f3feff1081e49b3b240f7"
integrity sha512-gy4MqNljXQdsqPznBb+Jk//4wTI1LdlamJE3MG9m3tHnVOM/9kH2xUQc+wsrIG1hPUqj10VrTw81A/oGA8Rjbw==
"@yandex/ymaps3-minimap@>=0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@yandex/ymaps3-minimap/-/ymaps3-minimap-0.0.4.tgz#8adb990572bab8d14f853e741495858877837f95"
integrity sha512-Mw8WKNxVJZHPy9ZZPvU/3HscHXEfJKkcoRJSAAFysDfihpcdPRR8mc/zflFjQmg9VaaLLD9aSR7yT5O+EVpwZA==
"@yandex/ymaps3-resizer@>=0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@yandex/ymaps3-resizer/-/ymaps3-resizer-0.0.2.tgz#66dfe02555664b9995fddafe385890b77c2986ac"
integrity sha512-1D/b8+GqrZOOXjuPYnffNBdpAtrUyfJPhPY6AaaJKdaF87Rx1JJKYARktblEIPjYjie9ksj3gnVKVh9OLPVLsQ==
"@yandex/ymaps3-signpost@>=0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@yandex/ymaps3-signpost/-/ymaps3-signpost-0.0.2.tgz#191c66c1f794d51d5a150f31b3422b0bf12e25b4"
integrity sha512-p7UYkzrcfKloAPqcvK+rd2WHr1kW5ENLlwTtmY7B+bcjye32HALCmBn53VLnuUAl9xW6s6M1RGdJ8gdmubR5aQ==
"@yandex/ymaps3-spinner@>=0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@yandex/ymaps3-spinner/-/ymaps3-spinner-0.0.2.tgz#6ef85d717d3b9c44c939c6dfcd0c0587fdb89820"
integrity sha512-nDze/MfYAn7JIbBOzYNBhrEhgF1RHnymJGT7W+y+uVKCTmaUaGXOMeZF2Z8+GuR3BvszZ73fWFE3IfGySOSw+g==
dependencies:
spin.js "^4.1.2"
"@yandex/ymaps3-types@>=1.0.17734864":
version "1.0.17798529"
resolved "https://registry.yarnpkg.com/@yandex/ymaps3-types/-/ymaps3-types-1.0.17798529.tgz#838710f1019049bdeac4bcc7565b1d3d1cf4790d"
integrity sha512-sPQaXnsDRvTkBoo6v4yJWcX0rq2p2nH+nyE0K5gkA0LcWAOAQHFLcdTj1YOFU7WEmk+BzMK61WVOsItKsjyB1Q==
abbrev@^3.0.0: abbrev@^3.0.0:
version "3.0.1" version "3.0.1"
@ -7897,6 +7934,11 @@ speakingurl@^14.0.1:
resolved "https://registry.yarnpkg.com/speakingurl/-/speakingurl-14.0.1.tgz#f37ec8ddc4ab98e9600c1c9ec324a8c48d772a53" resolved "https://registry.yarnpkg.com/speakingurl/-/speakingurl-14.0.1.tgz#f37ec8ddc4ab98e9600c1c9ec324a8c48d772a53"
integrity sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ== integrity sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==
spin.js@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/spin.js/-/spin.js-4.1.2.tgz#866180f0489ce90432201363e71c314f0db28824"
integrity sha512-ua/yEpxEwyEUWs57tMQYdik/KJ12sQRyMXjSlK/Ai927aEUDVY3FXUi4ml4VvlLCTQNIjC6tHyjSLBrJzFAqMA==
srvx@^0.8.9: srvx@^0.8.9:
version "0.8.16" version "0.8.16"
resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.8.16.tgz#f2582bd747351b5b0a1c65bce8179bae83e8b2a6" resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.8.16.tgz#f2582bd747351b5b0a1c65bce8179bae83e8b2a6"
@ -8797,6 +8839,20 @@ vue-router@^4.5.1:
dependencies: dependencies:
"@vue/devtools-api" "^6.6.4" "@vue/devtools-api" "^6.6.4"
vue-yandex-maps@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/vue-yandex-maps/-/vue-yandex-maps-2.2.3.tgz#a86d7dc55cfe3eec8f7e796f7a7314ca05e41214"
integrity sha512-4C7qt4Aakvjvtj3p8yhu7JbvL5ruvjQ7gZzdE96VsWkVVbVF1+razKzh34gMHaWSjzxPUyjymAdx7FsG3H1Htg==
dependencies:
"@yandex/ymaps3-context-menu" ">=0.0.2"
"@yandex/ymaps3-default-ui-theme" ">=0.0.19"
"@yandex/ymaps3-drawer-control" ">=0.0.2"
"@yandex/ymaps3-minimap" ">=0.0.4"
"@yandex/ymaps3-resizer" ">=0.0.2"
"@yandex/ymaps3-signpost" ">=0.0.2"
"@yandex/ymaps3-spinner" ">=0.0.2"
"@yandex/ymaps3-types" ">=1.0.17734864"
vue@^3.4.5, vue@^3.5.13, vue@^3.5.14, vue@^3.5.17, vue@^3.5.22: vue@^3.4.5, vue@^3.5.13, vue@^3.5.14, vue@^3.5.17, vue@^3.5.22:
version "3.5.22" version "3.5.22"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.22.tgz#2b8ddb94ee4b640ef12fe7f6efe1cf16f3b582e7" resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.22.tgz#2b8ddb94ee4b640ef12fe7f6efe1cf16f3b582e7"