Compare commits

13 Commits

Author SHA1 Message Date
alsaze
4b60ac3da7 refactoring
All checks were successful
Deploy / build (push) Successful in 57s
2025-11-21 14:26:28 +03:00
alsaze
8e64ee0c7a refactoring
All checks were successful
Deploy / build (push) Successful in 51s
2025-11-21 13:36:52 +03:00
alsaze
769720024f refactoring
All checks were successful
Deploy / build (push) Successful in 1m2s
2025-11-21 13:33:00 +03:00
alsaze
c06d1d336d refactoring
All checks were successful
Deploy / build (push) Successful in 51s
2025-11-21 13:21:07 +03:00
alsaze
3d6a71f8e6 refactoring
All checks were successful
Deploy / build (push) Successful in 55s
2025-11-21 12:55:24 +03:00
alsaze
2e01f58e67 refactoring
Some checks failed
Deploy / build (push) Has been cancelled
2025-11-21 12:48:00 +03:00
alsaze
2984e3780a карты пвз
All checks were successful
Deploy / build (push) Successful in 11m3s
2025-11-19 21:12:47 +03:00
alsaze
d1ee94e5c4 карты пвз
All checks were successful
Deploy / build (push) Successful in 6m4s
2025-11-13 16:41:12 +03:00
alsaze
f857c40ca2 карты пвз
Some checks failed
Deploy / build (push) Failing after 7s
2025-11-13 16:24:10 +03:00
alsaze
bd4edfdade карты пвз
All checks were successful
Deploy / build (push) Successful in 55s
2025-11-12 13:19:47 +03:00
alsaze
e5420edc64 карты пвз 2025-11-11 20:10:11 +03:00
alsaze
55103d3778 карты пвз
All checks were successful
Deploy / build (push) Successful in 56s
2025-11-11 18:21:20 +03:00
alsaze
742ebb4e74 карты пвз 2025-11-11 18:13:13 +03:00
35 changed files with 891 additions and 562 deletions

View File

@@ -1,29 +1,3 @@
@use 'typography' as *;
@use 'utils' as *;
// Typography
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 100%;
color: #ffffff;
}
h1 {
@include h1('h1');
}
h2 {
@include h2('h2');
}
h3 {
@include h3('h3');
}
//скроллбар //скроллбар
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;

5
assets/scss/mixins.scss Normal file
View File

@@ -0,0 +1,5 @@
@mixin mobile {
@media (max-width: 768px) {
@content;
}
}

View File

@@ -1,121 +0,0 @@
@use 'utils' as *;
// Font Face Declarations
@font-face {
font-family: 'Inter';
src:
url('/fonts/Inter-Regular.woff2') format('woff2'),
url('/fonts/Inter-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
url('/fonts/Inter-Medium.woff2') format('woff2'),
url('/fonts/Inter-Medium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
url('/fonts/Inter-SemiBold.woff2') format('woff2'),
url('/fonts/Inter-SemiBold.woff') format('woff');
font-weight: 600;
font-style: normal;
font-display: swap;
}
// Font Variables
$font-family-base:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
// Font Weights
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
// Custom type
@mixin font($size, $weight, $lineHeight, $namespace: null, $onlyVars: false) {
@if ($namespace) {
@if ($onlyVars) {
--#{$namespace}-font-size: #{$size};
--#{$namespace}-font-weight: #{$weight};
--#{$namespace}-line-height: #{$lineHeight};
} @else {
font-size: var(--#{$namespace}-font-size, $size);
font-weight: var(--#{$namespace}-font-weight, $weight);
line-height: var(--#{$namespace}-line-height, $lineHeight);
}
} @else {
font-size: $size;
font-weight: $weight;
line-height: $lineHeight;
}
}
/* Headline */
@mixin h1($namespace: null, $onlyVars: false) {
@include font(32px, $font-weight-semibold, 100%, $namespace, $onlyVars);
@include mobile {
font-size: 24px;
}
}
@mixin h2($namespace: null, $onlyVars: false) {
@include font(24px, $font-weight-semibold, 100%, $namespace, $onlyVars);
@include mobile {
font-size: 20px;
}
}
@mixin h3($namespace: null, $onlyVars: false) {
@include font(16px, $font-weight-semibold, 140%, $namespace, $onlyVars);
}
/* Text */
// 16 medium-(medium/bold)
@mixin txt-m($namespace: null, $onlyVars: false) {
@include font(16px, $font-weight-regular, 100%, $namespace, $onlyVars);
}
@mixin txt-m-m($namespace: null, $onlyVars: false) {
@include font(16px, $font-weight-medium, 100%, $namespace, $onlyVars);
}
@mixin txt-m-b($namespace: null, $onlyVars: false) {
@include font(16px, $font-weight-semibold, 100%, $namespace, $onlyVars);
}
// 14 regular-(medium/bold)
@mixin txt-r($namespace: null, $onlyVars: false) {
@include font(14px, $font-weight-regular, 20px, $namespace, $onlyVars);
}
@mixin txt-r-m($namespace: null, $onlyVars: false) {
@include font(14px, $font-weight-medium, 20px, $namespace, $onlyVars);
}
@mixin txt-r-b($namespace: null, $onlyVars: false) {
@include font(14px, $font-weight-semibold, 20px, $namespace, $onlyVars);
}
// 12 text small-(medium/bold)
@mixin txt-s($namespace: null, $onlyVars: false) {
@include font(12px, $font-weight-regular, 18px, $namespace, $onlyVars);
}
@mixin txt-s-m($namespace: null, $onlyVars: false) {
@include font(12px, $font-weight-medium, 18px, $namespace, $onlyVars);
}
@mixin txt-s-b($namespace: null, $onlyVars: false) {
@include font(12px, $font-weight-semibold, 18px, $namespace, $onlyVars);
}
// 10 text-tiny
@mixin txt-t($namespace: null, $onlyVars: false) {
@include font(10px, $font-weight-medium, 15px, $namespace, $onlyVars);
}

View File

@@ -1,148 +0,0 @@
@use 'sass:color';
@mixin mobile {
@media (max-width: 768px) {
@content;
}
}
$indents: 0 2 4 5 6 8 10 12 15 16 18 20 24 25 28 30 32 36 40 48 50 52 60 64;
@each $i in $indents {
.m#{$i} {
margin: #{$i}px;
}
.mx#{$i} {
margin-left: #{$i}px;
margin-right: #{$i}px;
}
.my#{$i} {
margin-top: #{$i}px;
margin-bottom: #{$i}px;
}
.mt#{$i} {
margin-top: #{$i}px;
}
.mb#{$i} {
margin-bottom: #{$i}px;
}
.ml#{$i} {
margin-left: #{$i}px;
}
.mr#{$i} {
margin-right: #{$i}px;
}
.p#{$i} {
padding: #{$i}px;
}
.px#{$i} {
padding-left: #{$i}px;
padding-right: #{$i}px;
}
.py#{$i} {
padding-top: #{$i}px;
padding-bottom: #{$i}px;
}
.pt#{$i} {
padding-top: #{$i}px;
}
.pb#{$i} {
padding-bottom: #{$i}px;
}
.pl#{$i} {
padding-left: #{$i}px;
}
.pr#{$i} {
padding-right: #{$i}px;
}
}
.mla {
margin-left: auto;
}
.mra {
margin-left: auto;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
@each $align in ('left', 'right', 'center') {
.text-align-#{$align} {
text-align: #{$align};
}
}
.w-25 { width: 25% !important; }
.w-50 { width: 50% !important; }
.w-75 { width: 75% !important; }
.w-100 { width: 100% !important; }
.d-none { display: none !important; }
.d-inline { display: inline !important; }
.d-inline-block { display: inline-block !important; }
.d-block { display: block !important; }
.d-table { display: table !important; }
.d-table-row { display: table-row !important; }
.d-table-cell { display: table-cell !important; }
.d-flex { display: flex !important; }
.d-inline-flex { display: inline-flex !important; }
.flex-row { flex-direction: row !important; }
.flex-column { flex-direction: column !important; }
.flex-row-reverse { flex-direction: row-reverse !important; }
.flex-column-reverse { flex-direction: column-reverse !important; }
.flex-wrap { flex-wrap: wrap !important; }
.flex-nowrap { flex-wrap: nowrap !important; }
.flex-wrap-reverse { flex-wrap: wrap-reverse !important; }
.justify-content-start { justify-content: flex-start !important; }
.justify-content-end { justify-content: flex-end !important; }
.justify-content-center { justify-content: center !important; }
.justify-content-between { justify-content: space-between !important; }
.justify-content-around { justify-content: space-around !important; }
.align-items-start { align-items: flex-start !important; }
.align-items-end { align-items: flex-end !important; }
.align-items-center { align-items: center !important; }
.align-items-baseline { align-items: baseline !important; }
.align-items-stretch { align-items: stretch !important; }
.align-content-start { align-content: flex-start !important; }
.align-content-end { align-content: flex-end !important; }
.align-content-center { align-content: center !important; }
.align-content-between { align-content: space-between !important; }
.align-content-around { align-content: space-around !important; }
.align-content-stretch { align-content: stretch !important; }
.align-self-auto { align-self: auto !important; }
.align-self-start { align-self: flex-start !important; }
.align-self-end { align-self: flex-end !important; }
.align-self-center { align-self: center !important; }
.align-self-baseline { align-self: baseline !important; }
.align-self-stretch { align-self: stretch !important; }
.text-align-center { text-align: center !important; }
.text-align-left { text-align: left !important; }
.text-align-right { text-align: right !important; }
.cursor-pointer { cursor: pointer !important; }
.cursor-not-allowed { cursor: not-allowed !important; }
.cursor-progress { cursor: progress !important; }

View File

@@ -11,8 +11,13 @@
</li> </li>
<li class="delivery-info__item"> <li class="delivery-info__item">
<Icon class="delivery-info__icon" name="lucide:shirt" size="xl" /> <Icon
<span class="delivery-info__text">{{ checkoutPickupPoint?.pickup_services?.is_fitting_allowed ? 'с примеркой' : 'без примерки' }}</span> class="delivery-info__icon"
:name="checkoutPickupPoint?.pickup_services?.is_fitting_allowed ? 'lucide:shirt' : 'i-lucide-ban'" size="xl"
/>
<span class="delivery-info__text">
{{ checkoutPickupPoint?.pickup_services?.is_fitting_allowed ? 'с примеркой' : 'без примерки' }}
</span>
</li> </li>
<li class="delivery-info__item"> <li class="delivery-info__item">

View File

@@ -0,0 +1,96 @@
<template>
<div class="delivery__search">
<UInput
v-model="searchTerm"
size="xl"
class="w-full"
placeholder="Выберите пункт выдачи"
:ui="{ trailing: 'pe-1' }"
>
<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="pickup-point-card__items">
<div
v-for="pickupPoint in filteredPoints"
:key="pickupPoint.id"
class="pickup-point-card"
@click="onSelectPoint(pickupPoint)"
>
<div class="pickup-point-card__content">
<h3>Yandex</h3>
<p>{{ `${pickupPoint?.address?.street} ${pickupPoint?.address?.house}` }}</p>
<h3>Как определить стоимость ?</h3>
</div>
<Icon class="pickup-point-card__action" name="lucide:chevron-right" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { PickupPoint } from '#shared/yandex_pvz'
import type { PropType } from 'vue'
import { defineEmits } from 'vue'
defineProps<{ filteredPoints: PickupPoint[] }>()
const emit = defineEmits(['update:checkout-pickup-point'])
const checkoutPickupPoint = defineModel('checkoutPickupPoint', {
type: Object as PropType<PickupPoint>,
default: () => undefined,
})
const searchTerm = defineModel('searchTerm', { type: String, default: '' })
const onSelectPoint = (pickupPoint: PickupPoint) => {
checkoutPickupPoint.value = pickupPoint
emit('update:checkout-pickup-point', pickupPoint)
}
</script>
<style lang="scss">
.pickup-point-card {
position: relative;
width: 100%;
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
cursor: pointer;
&__items {
height: calc(100dvh - 54px - 40px - 24px);
overflow-y: auto;
flex-shrink: 0;
@include mobile {
height: calc(100dvh - 72px - 40px - 32px);
}
}
&__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
&__action {
flex-shrink: 0;
width: 16px;
height: 16px;
color: #999;
}
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<UTabs v-model="fitting" :content="false" :items="tabs" size="sm" />
</template>
<script setup lang="ts">
import type { TabsItem } from '@nuxt/ui'
import type { PropType } from 'vue'
import { IPvzMapFittingTabs } from '#shared/types'
import { computed } from 'vue'
const fitting = defineModel('fitting', { type: Object as PropType<IPvzMapFittingTabs>, default: () => IPvzMapFittingTabs.ALL })
const tabs = computed<TabsItem[]>(() => [
{
value: IPvzMapFittingTabs.ALL,
label: fitting.value === IPvzMapFittingTabs.ALL ? 'все' : '',
icon: 'i-lucide-globe',
},
{
value: IPvzMapFittingTabs.ALLOW,
label: fitting.value === IPvzMapFittingTabs.ALLOW ? 'с примеркой' : '',
icon: 'i-lucide-shirt',
},
{
value: IPvzMapFittingTabs.FORBID,
label: fitting.value === IPvzMapFittingTabs.FORBID ? 'без примерки' : '',
icon: 'i-lucide-ban',
},
])
</script>

View File

@@ -0,0 +1,27 @@
<template>
<UTabs v-model="activeTab" :content="false" :items="tabs" size="sm" />
</template>
<script setup lang="ts">
import type { TabsItem } from '@nuxt/ui'
import type { PropType } from 'vue'
import { IPvzMapTabs } from '#shared/types'
import { computed } from 'vue'
const activeTab = defineModel('activeTab', { type: Object as PropType<IPvzMapTabs>, default: () => IPvzMapTabs.MAP })
const tabs = computed<TabsItem[]>(() =>
[
{
value: IPvzMapTabs.MAP,
label: activeTab.value === IPvzMapTabs.MAP ? 'Карта' : '',
icon: 'lucide:map-pin',
},
{
value: IPvzMapTabs.LIST,
label: activeTab.value === IPvzMapTabs.LIST ? 'Список' : '',
icon: 'i-lucide-list',
},
],
)
</script>

View File

@@ -1,6 +1,37 @@
<template> <template>
<div> <div>
<UPageCard> <teleport
v-if="isMobile && isSummary" to="footer"
>
<UPageCard
class="pay-block--mobile"
variant="ghost"
:ui="{
body: 'w-full',
footer: 'hidden',
}"
>
<template #body>
<div class="d-flex flex-row">
<div>
<div v-if="cartSum">
<ProductPrice :is-headline="false" :price="cartSum" />
</div>
<div>{{ `${cart?.line_items?.length} шт` }}</div>
</div>
<UButton
class="justify-center text-center w-full ml10"
size="xl"
:label="pay ? 'оформить заказ' : 'перейти к оформлению'"
@click="pay ? createOrder() : router.push(`/checkout/delivery`)"
/>
</div>
</template>
</UPageCard>
</teleport>
<UPageCard v-else>
<template #body> <template #body>
<div>товары {{ `(${cart?.line_items?.length} шт)` }}</div> <div>товары {{ `(${cart?.line_items?.length} шт)` }}</div>
<div v-if="cartSum"> <div v-if="cartSum">
@@ -21,27 +52,35 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { IBspb } from '~/server/shared/types/bspb' import type { BsbpCreateResponse } from '#shared/bsbp_create'
import { useMediaQuery } from '@vueuse/core'
defineProps({ defineProps({
pay: { pay: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isSummary: {
type: Boolean,
default: false,
},
}) })
const router = useRouter() const router = useRouter()
const isMobile = useMediaQuery('(max-width: 1280px)', {
ssrWidth: 768,
})
const { cart, cartSum } = useCart() const { cart, cartSum } = useCart()
const createOrder = async () => { const createOrder = async () => {
const { data } = await useFetch<IBspb>('/api/bspb', { const { data } = await useFetch<BsbpCreateResponse>('/api/bsbp_create', {
method: 'POST', method: 'POST',
body: { body: {
order: { order: {
typeRid: 'Purchase', typeRid: 'Purchase',
amount: cartSum, amount: cartSum,
currency: 'RUB', currency: 'RUB',
hppRedirectUrl: `${import.meta.env.VITE_BASE_URL}/cart`, hppRedirectUrl: `${process.env.VITE_BASE_URL}/cart`,
}, },
}, },
}) })
@@ -50,3 +89,15 @@ const createOrder = async () => {
window.open(redirectUrl, '_blank') window.open(redirectUrl, '_blank')
} }
</script> </script>
<style lang="scss">
.pay-block {
&--mobile {
position: sticky;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
}
}
</style>

View File

@@ -93,8 +93,6 @@ function addToCartBtn() {
</script> </script>
<style lang="scss"> <style lang="scss">
@use '~/assets/scss/utils' as *;
.product-description { .product-description {
width: 100%; width: 100%;
padding: 30px 30px 0; padding: 30px 30px 0;

View File

@@ -24,7 +24,7 @@
:key="pickupPoint.id" :key="pickupPoint.id"
position="top-center left-center" position="top-center left-center"
:settings="{ coordinates: [pickupPoint.position.longitude, pickupPoint.position.latitude] }" :settings="{ coordinates: [pickupPoint.position.longitude, pickupPoint.position.latitude] }"
@click="$emit('update:modelValue', pickupPoint)" @click="onSelectPoint(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" />
@@ -37,41 +37,66 @@
</div> </div>
</template> </template>
</YandexMapClusterer> </YandexMapClusterer>
<YandexMapControls :settings="{ position: isMobile ? 'bottom left' : 'top left', orientation: 'vertical' }">
<YandexMapControl>
<MapControlFitting v-model:fitting="fitting" />
</YandexMapControl>
</YandexMapControls>
<YandexMapControls v-if="isMobile" :settings="{ position: 'bottom right', orientation: 'vertical' }">
<YandexMapControl>
<MapControlTabs v-model:active-tab="activeTab" />
</YandexMapControl>
</YandexMapControls>
</YandexMap> </YandexMap>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { PickupPoint } from '#shared/yandex_pvz'
import type { 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 type { PropType } from 'vue'
import { useGeolocation } from '@vueuse/core' import { IPvzMapFittingTabs, IPvzMapTabs } from '#shared/types'
import { computed, shallowRef } from 'vue' import { useGeolocation, useMediaQuery } from '@vueuse/core'
import { computed, defineEmits, shallowRef } from 'vue'
import { import {
YandexMap, YandexMap,
YandexMapClusterer, YandexMapClusterer,
YandexMapControl,
YandexMapControls,
YandexMapDefaultFeaturesLayer, YandexMapDefaultFeaturesLayer,
YandexMapDefaultSchemeLayer, YandexMapDefaultSchemeLayer,
YandexMapMarker, YandexMapMarker,
} from 'vue-yandex-maps' } from 'vue-yandex-maps'
import MapControlFitting from '~/components/MapControlFitting.vue'
import MapControlTabs from '~/components/MapControlTabs.vue'
const props = defineProps<{ modelValue: PickupPoint, pickupPoints: PickupPoint[] }>() defineProps<{ pickupPoints: PickupPoint[] }>()
const emit = defineEmits(['update:checkout-pickup-point'])
defineEmits<{ const checkoutPickupPoint = defineModel('checkoutPickupPoint', { type: Object as PropType<PickupPoint>, default: () => undefined })
(e: 'update:modelValue', value: PickupPoint | undefined): void const activeTab = defineModel('activeTab', { type: Object as PropType<IPvzMapTabs>, default: () => IPvzMapTabs.MAP })
}>() const fitting = defineModel('fitting', { type: Object as PropType<IPvzMapFittingTabs>, default: () => IPvzMapFittingTabs.ALL })
const { coords } = useGeolocation() const { coords } = useGeolocation()
const isMobile = useMediaQuery('(max-width: 1280px)')
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]])
const map = shallowRef<null | YMap>(null) const map = shallowRef<null | YMap>(null)
const hasCoords = computed(() => coords.value?.latitude !== Infinity && coords.value?.longitude !== Infinity) const hasCoords = computed(() => coords.value.latitude !== Infinity && coords.value.longitude !== Infinity)
const location = ref<YMapLocationRequest>({ const location = ref<YMapLocationRequest>({
zoom: 2, zoom: 2,
}) })
const onSelectPoint = (pickupPoint: PickupPoint) => {
checkoutPickupPoint.value = pickupPoint
emit('update:checkout-pickup-point', pickupPoint)
}
watch(coords, (newCoords) => { watch(coords, (newCoords) => {
if (newCoords && hasCoords.value) { if (newCoords && hasCoords.value) {
location.value = { location.value = {
@@ -82,7 +107,10 @@ watch(coords, (newCoords) => {
} }
}, { once: true }) }, { once: true })
watch(() => props.modelValue, (newPickupPoint) => { watch(() => checkoutPickupPoint.value, (newPickupPoint) => {
if (!newPickupPoint)
return
location.value = { location.value = {
center: [newPickupPoint.position.longitude, newPickupPoint.position.latitude], center: [newPickupPoint.position.longitude, newPickupPoint.position.latitude],
zoom: 18, zoom: 18,
@@ -132,6 +160,11 @@ watch(() => props.modelValue, (newPickupPoint) => {
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
&:hover &__icon {
color: var(--ui-primary);
transform: scale(1.1);
}
} }
.cluster { .cluster {

View File

@@ -23,6 +23,10 @@ export const useCart = () => {
cartItem?.variation_id === item?.variation_id), 1) cartItem?.variation_id === item?.variation_id), 1)
} }
const cartRemoveAllItems = () => {
cart.value = { line_items: [] }
}
const cartProducts = computed(() => cart?.value?.line_items?.map((line_item) => { const cartProducts = computed(() => cart?.value?.line_items?.map((line_item) => {
const { productsData } = useProduct(line_item?.variation_id.toString()) const { productsData } = useProduct(line_item?.variation_id.toString())
return productsData return productsData
@@ -37,6 +41,7 @@ export const useCart = () => {
cart, cart,
cartAddItem, cartAddItem,
cartRemoveItem, cartRemoveItem,
cartRemoveAllItems,
cartProducts, cartProducts,
cartSum, cartSum,

View File

@@ -1,9 +1,10 @@
import type { PickupPoint } from '~/server/shared/types/yandex_pvz' import type { PickupPoint } from '#shared/yandex_pvz'
import { createSharedComposable, useStorage } from '@vueuse/core' import { createSharedComposable, useMediaQuery, 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 isMobile = useMediaQuery('(max-width: 1280px)')
const checkoutPickupPoint = useStorage<PickupPoint | undefined>( const checkoutPickupPoint = useStorage<PickupPoint | undefined>(
'checkout-pickupPoint', 'checkout-pickupPoint',
@@ -32,23 +33,26 @@ export const useCheckout = createSharedComposable(() => {
const checkoutSteps = [ const checkoutSteps = [
{ {
step: 1, step: 1,
title: 'delivery', title: isMobile.value ? 'mobileDelivery' : 'delivery',
route: 'delivery',
}, },
{ {
step: 2, step: 2,
title: 'contacts', title: isMobile.value ? 'mobileContacts' : 'contacts',
route: 'contacts',
}, },
{ {
step: 3, step: 3,
title: 'summary', title: isMobile.value ? 'mobileSummary' : 'summary',
route: 'summary',
}, },
] ]
const currentCheckoutStep const currentCheckoutStep
= ref(checkoutSteps.find(value => value.title === route.path.split('/').pop()) || checkoutSteps[0]) = ref(checkoutSteps.find(value => value.route === route.path.split('/').pop()) || checkoutSteps[0])
function previewStep() { function previewStep() {
if (isPickupPointSelected.value) { if (isPickupPointSelected.value && !isMobile.value) {
setCheckoutPickupPoint(undefined) setCheckoutPickupPoint(undefined)
return return
} }
@@ -56,7 +60,7 @@ export const useCheckout = createSharedComposable(() => {
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]
router.push(`/checkout/${currentCheckoutStep?.value.title}`) router.push(`/checkout/${currentCheckoutStep?.value.route}`)
} }
else { else {
router.push(`/cart`) router.push(`/cart`)
@@ -67,12 +71,12 @@ export const useCheckout = createSharedComposable(() => {
const findIndex = checkoutSteps.findIndex(value => value.step === currentCheckoutStep.value.step) const findIndex = checkoutSteps.findIndex(value => value.step === currentCheckoutStep.value.step)
if (findIndex + 1 !== checkoutSteps.length) { if (findIndex + 1 !== checkoutSteps.length) {
currentCheckoutStep.value = checkoutSteps[findIndex + 1] currentCheckoutStep.value = checkoutSteps[findIndex + 1]
router.push(`/checkout/${currentCheckoutStep?.value.title}`) router.push(`/checkout/${currentCheckoutStep?.value.route}`)
} }
} }
function setStep(pathName: string) { function setStep(pathName: string) {
currentCheckoutStep.value = checkoutSteps.find(value => value.title === pathName) || checkoutSteps[0] currentCheckoutStep.value = checkoutSteps.find(value => value.route === pathName) || checkoutSteps[0]
router.push(`/checkout/${pathName}`) router.push(`/checkout/${pathName}`)
} }

View File

@@ -11,7 +11,10 @@
}, },
"checkoutSteps": { "checkoutSteps": {
"delivery": "Выберите адрес получения заказа", "delivery": "Выберите адрес получения заказа",
"mobileDelivery": "доставка",
"contacts": "Введите данные получателя", "contacts": "Введите данные получателя",
"summary": "Подтвердите заказ" "mobileContacts": "контакты",
"summary": "Подтвердите заказ",
"mobileSummary": "заказ"
} }
} }

View File

@@ -12,19 +12,28 @@
</div> </div>
</header> </header>
<main class="main"> <main class="main" :style="showFooter ? 'margin-bottom: 54px' : 'margin-bottom: 0'">
<UContainer class="container"> <UContainer class="container">
<slot /> <slot />
</UContainer> </UContainer>
</main> </main>
<footer class="footer" :class="{ 'footer--hidden': !showFooter }" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useCheckout } from '../composables/useCheckout' import { useMediaQuery } from '@vueuse/core'
import { useCheckout } from '~/composables/useCheckout'
const route = useRoute()
const { t } = useI18n()
const isMobile = useMediaQuery('(max-width: 1280px)', {
ssrWidth: 768,
})
const showFooter = computed(() => route.path !== '/checkout/delivery' && isMobile.value)
const { previewStep, currentCheckoutStep, checkoutSteps } = useCheckout() const { previewStep, currentCheckoutStep, checkoutSteps } = useCheckout()
const { t } = useI18n()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -63,8 +72,27 @@ const { t } = useI18n()
} }
} }
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
height: 70px;
background-color: #0f172b;
&--hidden {
display: none;
}
}
.main { .main {
flex: 1; flex: 1;
margin-top: 54px; margin-top: 54px;
margin-bottom: 64px;
&__hide-footer {
margin-bottom: 0 !important;
}
} }
</style> </style>

View File

@@ -32,15 +32,20 @@
</UContainer> </UContainer>
</main> </main>
<!-- <footer class="footer"> --> <footer class="footer" :class="{ 'footer--hidden': !(route.path === '/cart' && isMobile) }" />
<!-- Footer -->
<!-- </footer> -->
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMediaQuery } from '@vueuse/core'
const route = useRoute()
const router = useRouter() const router = useRouter()
const { cart } = useCart() const { cart } = useCart()
const isMobile = useMediaQuery('(max-width: 1280px)', {
ssrWidth: 768,
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -106,12 +111,22 @@ const { cart } = useCart()
} }
} }
.main { .footer {
flex: 1; position: fixed;
margin-top: 54px; bottom: 0;
left: 0;
right: 0;
z-index: 100;
height: 70px;
background-color: #0f172b;
&--hidden {
display: none;
}
} }
.footer { .main {
margin-top: auto; flex: 1;
margin-block: 54px 64px;
} }
</style> </style>

View File

@@ -1,4 +1,8 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
import { createResolver } from '@nuxt/kit'
const { resolve } = createResolver(import.meta.url)
export default defineNuxtConfig({ export default defineNuxtConfig({
ssr: true, ssr: true,
compatibilityDate: '2025-05-15', compatibilityDate: '2025-05-15',
@@ -18,7 +22,7 @@ export default defineNuxtConfig({
'vue-yandex-maps/nuxt', 'vue-yandex-maps/nuxt',
], ],
yandexMaps: { yandexMaps: {
apikey: process.env.VITE_YANDEX_MAPS_TOKEN, apikey: '13f4c06b-cb7e-4eeb-81f1-af52f12587b2', // неправильно, через .env ошибка в проде РАЗОБРАТСЯ
}, },
css: ['~/assets/css/main.css', '~/assets/scss/main.scss'], css: ['~/assets/css/main.css', '~/assets/scss/main.scss'],
i18n: { i18n: {
@@ -40,4 +44,16 @@ export default defineNuxtConfig({
], ],
}, },
}, },
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "${resolve('./assets/scss/mixins')}" as *;`,
},
},
},
},
fonts: {
provider: 'google',
},
}) })

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="cart"> <div class="cart">
<div class="cart__items"> <div v-if="cart.line_items.length > 0" class="cart__items">
<div <div
v-for="cartItem in cart?.line_items" v-for="cartItem in cart?.line_items"
:key="cartItem.variation_id" :key="cartItem.variation_id"
@@ -9,23 +9,34 @@
</div> </div>
</div> </div>
<PayBlock /> <div v-else>
Корзина пока что пуста
</div>
<ClientOnly>
<PayBlock :is-summary="true" />
</ClientOnly>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { WooOrderCreateResponse } from '#shared/woo_orders_create'
import { useCart } from '~/composables' import { useCart } from '~/composables'
import PayBlock from '../components/PayBlock.vue' import PayBlock from '../components/PayBlock.vue'
const route = useRoute() const route = useRoute()
const { cart } = useCart() const { cart, cartRemoveAllItems } = useCart()
const { checkoutContacts, checkoutPickupPoint } = useCheckout() const { checkoutContacts, checkoutPickupPoint } = useCheckout()
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
// Зарпос должен быть в админке bsbp, ждём доступов. Текущее решение просто создает order в WooCommerce
onMounted(async () => { onMounted(async () => {
if (!route?.query?.ID) if (!route?.query?.ID || cart.value.line_items.length === 0)
return return
await $fetch('/api/create', { await wait(5000)
await useFetch<WooOrderCreateResponse>('/api/woo_orders_create', {
method: 'POST', method: 'POST',
body: { body: {
payment_method: 'bacs', payment_method: 'bacs',
@@ -46,12 +57,12 @@ onMounted(async () => {
status: 'processing', status: 'processing',
}, },
}) })
cartRemoveAllItems()
}) })
</script> </script>
<style lang="scss"> <style lang="scss">
@use '~/assets/scss/utils' as *;
.cart { .cart {
margin-top: 120px; margin-top: 120px;
margin-inline: auto; margin-inline: auto;
@@ -63,6 +74,7 @@ onMounted(async () => {
@include mobile { @include mobile {
margin-top: 20px; margin-top: 20px;
flex-direction: column; flex-direction: column;
padding-inline: 16px;
} }
&__items { &__items {

View File

@@ -1,5 +1,5 @@
<template> <template>
<form class="contacts" @submit.prevent="onSubmit"> <form v-if="checkoutContacts" class="contacts" @submit.prevent="onSubmit">
<div> <div>
<UInput <UInput
id="name" id="name"
@@ -138,7 +138,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useCheckout } from '../../composables/useCheckout' import { useCheckout } from '~/composables/useCheckout'
const { checkoutContacts, setCheckoutContacts, nextStep } = useCheckout() const { checkoutContacts, setCheckoutContacts, nextStep } = useCheckout()
@@ -205,5 +205,9 @@ definePageMeta({
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
@include mobile {
padding-inline: 10px;
}
} }
</style> </style>

View File

@@ -1,66 +1,113 @@
<template> <template>
<div v-if="coords" class="delivery"> <div v-if="coords" class="delivery">
<div class="delivery__sidebar"> <!-- Desktop -->
<div v-if="!isMobile" class="delivery__sidebar">
<DeliveryInfo v-if="isPickupPointSelected" /> <DeliveryInfo v-if="isPickupPointSelected" />
<div v-else class="delivery__search"> <DeliverySearch
<UInput v-else
v-model="searchTerm" v-model:checkout-pickup-point="checkoutPickupPoint"
size="xl" v-model:search-term="searchTerm"
class="w-full" :filtered-points="filteredPoints"
placeholder="Выберите пункт выдачи" @update:checkout-pickup-point="updatePoint()"
:ui="{ trailing: 'pe-1' }" />
>
<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="checkoutPickupPoint = 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>
<!-- Mobile -->
<UDrawer
v-if="isMobile"
v-model:open="open"
fixed
:ui="{
content: 'fixed bg-default ring ring-default flex focus:outline-none overflow-hidden',
container: 'w-full flex flex-col gap-4 p-4 overflow-hidden',
body: 'flex-1 overflow-y-auto',
}"
>
<template #content>
<div class="px-4 pb-4">
<DeliveryInfo v-if="isPickupPointSelected" />
</div>
</template>
</UDrawer>
<UModal
v-model:open="openDeliverySearch"
fullscreen
:dismissible="false"
:ui="{
body: 'overflow-hidden',
overlay: 'bg-black/50',
header: 'hidden',
}"
>
<template #body>
<DeliverySearch
v-if="activeTab === IPvzMapTabs.LIST"
v-model:checkout-pickup-point="checkoutPickupPoint"
v-model:search-term="searchTerm"
:filtered-points="filteredPoints"
@update:checkout-pickup-point="updatePoint()"
/>
<UDrawer
v-if="isMobile"
v-model:open="open"
fixed
:ui="{
content: 'fixed bg-default ring ring-default flex focus:outline-none overflow-hidden',
container: 'w-full flex flex-col gap-4 p-4 overflow-hidden',
body: 'flex-1 overflow-y-auto',
}"
>
<template #content>
<div class="px-4 pb-4">
<DeliveryInfo v-if="isPickupPointSelected" />
</div>
</template>
</UDrawer>
</template>
<template #footer>
<div class="d-flex flex-row w-full justify-between">
<MapControlFitting v-model:fitting="fitting" />
<MapControlTabs v-model:active-tab="activeTab" />
</div>
</template>
</UModal>
<!-- Desktop Mobile -->
<PvzMap <PvzMap
v-model="checkoutPickupPoint" v-model:checkout-pickup-point="checkoutPickupPoint"
v-model:active-tab="activeTab"
v-model:fitting="fitting"
:pickup-points="filteredPoints" :pickup-points="filteredPoints"
@update:model-value="setCheckoutPickupPoint(checkoutPickupPoint)" @update:checkout-pickup-point="updatePoint()"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { PickupPoint, YandexPvzResponse } from '~/server/shared/types/yandex_pvz' import type { YandexLocationDetectResponse } from '#shared/yandex_location_detect'
import { useGeolocation } from '@vueuse/core' import type { YandexPvzPoint, YandexPvzResponse } from '#shared/yandex_pvz'
import { IPvzMapFittingTabs, IPvzMapTabs } from '#shared/types'
import { useGeolocation, useMediaQuery } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import DeliveryInfo from '~/components/DeliveryInfo.vue' import DeliveryInfo from '~/components/DeliveryInfo.vue'
import DeliverySearch from '~/components/DeliverySearch.vue'
import PvzMap from '~/components/PvzMap.vue' import PvzMap from '~/components/PvzMap.vue'
import { useCheckout } from '~/composables/useCheckout' import { useCheckout } from '~/composables/useCheckout'
const { setCheckoutPickupPoint, isPickupPointSelected, checkoutPickupPoint } = useCheckout() const { setCheckoutPickupPoint, isPickupPointSelected, checkoutPickupPoint } = useCheckout()
const { coords } = useGeolocation() const { coords } = useGeolocation()
const open = ref(false)
const isMobile = useMediaQuery('(max-width: 1280px)')
const yandexPvz = ref<YandexPvzResponse>() const yandexPvz = ref<YandexPvzResponse>()
const searchTerm = ref('') const searchTerm = ref('')
const activeTab = ref()
const fitting = ref(IPvzMapFittingTabs.ALL)
const openDeliverySearch = ref()
const waitForCoords = () => const waitForCoords = () =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
@@ -82,7 +129,7 @@ onMounted(async () => {
const openstreetmap = await response.json() const openstreetmap = await response.json()
// получение geo_id из названию города // получение geo_id из названию города
const { data: yandexLocation } = await useFetch('/api/yandex_location', { const { data: yandexLocation } = await useFetch<YandexLocationDetectResponse>('/api/yandex_location_detect', {
method: 'POST', method: 'POST',
body: { body: {
location: openstreetmap?.address?.city, location: openstreetmap?.address?.city,
@@ -100,15 +147,30 @@ onMounted(async () => {
yandexPvz.value = yandexPvzApi.value yandexPvz.value = yandexPvzApi.value
}) })
// TODO сделать отдельный компонент UISearch const filteredPoints = computed<YandexPvzPoint[]>(() => {
const filteredPoints = computed<PickupPoint[]>(() => { const points = yandexPvz.value?.points || []
if (!searchTerm.value || searchTerm.value === '') { const term = searchTerm.value?.toLowerCase() || ''
return yandexPvz.value?.points || []
}
return yandexPvz.value?.points?.filter(point => return points.filter((point) => {
point?.address?.full_address?.toLowerCase().includes(searchTerm.value.toLowerCase()), const matchesSearch = !term || point.address.full_address.toLowerCase().includes(term)
) || []
const isAllowed = point.pickup_services.is_fitting_allowed
const matchesFitting
= fitting.value === IPvzMapFittingTabs.ALL
|| (fitting.value === IPvzMapFittingTabs.ALLOW && isAllowed)
|| (fitting.value === IPvzMapFittingTabs.FORBID && !isAllowed)
return matchesSearch && matchesFitting
})
})
function updatePoint() {
setCheckoutPickupPoint(checkoutPickupPoint.value)
open.value = true
}
watch(() => activeTab.value, () => {
openDeliverySearch.value = activeTab.value === IPvzMapTabs.LIST
}) })
definePageMeta({ definePageMeta({
@@ -117,8 +179,6 @@ 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;
@@ -130,36 +190,5 @@ definePageMeta({
width: 410px; width: 410px;
padding-inline: 24px; padding-inline: 24px;
} }
&__items {
height: calc(100dvh - 54px - 40px - 24px);
overflow-y: auto;
flex-shrink: 0;
}
}
.pickup-point-card {
position: relative;
width: 100%;
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
cursor: pointer;
&__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
&__action {
flex-shrink: 0;
width: 16px;
height: 16px;
color: #999;
}
} }
</style> </style>

View File

@@ -58,7 +58,9 @@
</div> </div>
</div> </div>
<PayBlock pay /> <ClientOnly>
<PayBlock pay :is-summary="true" />
</ClientOnly>
</div> </div>
</template> </template>
@@ -75,8 +77,6 @@ definePageMeta({
</script> </script>
<style lang="scss"> <style lang="scss">
@use '~/assets/scss/utils' as *;
.summary { .summary {
max-width: 1200px; max-width: 1200px;
margin-inline: auto; margin-inline: auto;
@@ -85,10 +85,18 @@ definePageMeta({
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
@include mobile {
flex-direction: column;
}
&__info { &__info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
@include mobile {
margin-bottom: 26px;
}
} }
&__items { &__items {

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="index"> <div class="index">
<div class="cards-list"> <div v-if="productCardData.length" class="cards-list">
<div <div
v-for="product in productCardData" v-for="product in pizda"
:key="product.id" :key="product.id"
class="card" class="card"
@click="router.push(`/product/${product.id}`)" @click="router.push(`/product/${product.id}`)"
@@ -30,12 +30,15 @@
import { useProductsList } from '~/composables' import { useProductsList } from '~/composables'
const { productCardData } = useProductsList() const { productCardData } = useProductsList()
// Карточка вариативного товара с разными цветами в процессе разработки, поэтому отображаем только карточку товара с одним цветов
const pizda = computed(() => {
return [productCardData.value.find(xueta => xueta.name === 'Пальто мужское'), productCardData.value.find(xueta => xueta.name === 'Пальто мужское'), productCardData.value.find(xueta => xueta.name === 'Пальто мужское'), productCardData.value.find(xueta => xueta.name === 'Пальто мужское')]
})
const router = useRouter() const router = useRouter()
</script> </script>
<style lang="scss"> <style lang="scss">
@use '~/assets/scss/utils' as *;
.cards-list { .cards-list {
padding: 15px; padding: 15px;

View File

@@ -102,8 +102,6 @@ onUnmounted(() => {
</script> </script>
<style lang="scss"> <style lang="scss">
@use '~/assets/scss/utils' as *;
.product { .product {
position: relative; position: relative;
width: 100%; width: 100%;

38
server/api/bsbp_create.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { BsbpCreateRequest, BsbpCreateResponse } from '#shared/bsbp_create'
import https from 'node:https'
import axios from 'axios'
import { defineEventHandler, readBody } from 'h3'
export default defineEventHandler(async (event): Promise<BsbpCreateResponse | { error: string }> => {
const merchantId = process.env.BSPB_MERCHANT_ID!
const merchantPassword = process.env.BSPB_MERCHANT_PASSWORD!
const apiUrl = process.env.BSPB_API_URL!
const assetsStorage = useStorage('assets:server')
const bspbKey = await assetsStorage.getItem<string>('pgtest_key.key')
const bspbCert = await assetsStorage.getItem<string>('pgtest_cer_2025.pem')
const agent = new https.Agent({
key: bspbKey!,
cert: bspbCert!,
rejectUnauthorized: false,
})
const orderData = await readBody<BsbpCreateRequest>(event)
try {
const response = await axios.post<BsbpCreateResponse>(`${apiUrl}/order`, orderData, {
httpsAgent: agent,
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${Buffer.from(`TerminalSys/${merchantId}:${merchantPassword}`).toString('base64')}`,
},
})
return response.data
}
catch (e: any) {
return { error: e?.message ?? 'Unknown BSPB error' }
}
},
)

View File

@@ -1,32 +0,0 @@
import https from 'node:https'
import axios from 'axios'
import { defineEventHandler, readBody } from 'h3'
export default defineEventHandler(async (event) => {
const merchantId = process.env.BSPB_MERCHANT_ID!
const merchantPassword = process.env.BSPB_MERCHANT_PASSWORD!
const apiUrl = process.env.BSPB_API_URL!
const assetsStorage = useStorage('assets:server')
const bspbKey = await assetsStorage.getItem<string>('pgtest_key.key')
const bspbCert = await assetsStorage.getItem<string>('pgtest_cer_2025.pem')
const agent = new https.Agent({
key: bspbKey!,
cert: bspbCert!,
rejectUnauthorized: false,
})
const orderData = await readBody(event)
const response = await axios.post(`${apiUrl}/order`, orderData, {
httpsAgent: agent,
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${Buffer.from(`TerminalSys/${merchantId}:${merchantPassword}`).toString('base64')}`,
},
})
return response?.data || []
})

View File

@@ -1,9 +1,10 @@
import type { WooOrderCreateRequest, WooOrderCreateResponse } from '#shared/woo_orders_create'
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<WooOrderCreateResponse | { error: string }> => {
try { try {
const orderData = await readBody(event) const orderData = await readBody<WooOrderCreateRequest>(event)
const requestUrl = 'https://wp.koptilnya.xyz/wp-json/wc/v3/orders' const requestUrl = 'https://wp.koptilnya.xyz/wp-json/wc/v3/orders'
const consumerKey = 'ck_8b5477a1573ce6038ef1367f25d95cede1de4559' const consumerKey = 'ck_8b5477a1573ce6038ef1367f25d95cede1de4559'
@@ -11,7 +12,7 @@ export default defineEventHandler(async (event) => {
const encodedAuth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64') const encodedAuth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64')
const response = await axios.post(requestUrl, orderData, { const response = await axios.post<WooOrderCreateResponse>(requestUrl, orderData, {
headers: { headers: {
'Authorization': `Basic ${encodedAuth}`, 'Authorization': `Basic ${encodedAuth}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,13 +1,14 @@
import type { YandexLocationDetectRequest, YandexLocationDetectResponse } from '#shared/yandex_location_detect'
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<YandexLocationDetectResponse | { error: string }> => {
try { try {
const data = await readBody(event) const data = await readBody<YandexLocationDetectRequest>(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<YandexLocationDetectResponse>(
`${apiUrl}/location/detect`, `${apiUrl}/location/detect`,
{ {
location: data?.location, location: data?.location,

View File

@@ -1,10 +1,10 @@
import type { YandexPvzResponse } from '~/server/shared/types/yandex_pvz' import type { YandexPvzRequest, YandexPvzResponse } from '#shared/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): Promise<YandexPvzResponse | { error: string }> => { export default defineEventHandler(async (event): Promise<YandexPvzResponse | { error: string }> => {
try { try {
const data = await readBody(event) const data = await readBody<YandexPvzRequest>(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!

View File

@@ -1,10 +0,0 @@
export interface IBspb {
order: {
id: number
hppUrl: string
password: string
accessToken: string
status: string
cvv2AuthStatus: string
}
}

View File

@@ -1,75 +0,0 @@
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[]
}

23
shared/bsbp_create.ts Normal file
View File

@@ -0,0 +1,23 @@
export interface BsbpOrder {
typeRid: string
amount: number
currency: string
title?: string
description?: string
hppRedirectUrl: string
}
export interface BsbpCreateRequest {
order: BsbpOrder
}
export interface BsbpCreateResponse {
order: {
id: number
hppUrl: string
password: string
accessToken: string
status: string
cvv2AuthStatus: string
}
}

9
shared/types.ts Normal file
View File

@@ -0,0 +1,9 @@
export enum IPvzMapTabs {
MAP = 'map',
LIST = 'list',
}
export enum IPvzMapFittingTabs {
ALL = 'all',
ALLOW = 'allow',
FORBID = 'Forbid',
}

160
shared/woo_orders_create.ts Normal file
View File

@@ -0,0 +1,160 @@
// ------------------------------------
// REQUEST: Create WooCommerce Order
// ------------------------------------
export interface WooOrderCreateBilling {
first_name: string
last_name: string
address_1: string
address_2: string
city: string
state: string
postcode: string
country: string
email: string
phone: string
}
export interface WooOrderCreateShipping {
first_name: string
last_name: string
address_1: string
address_2: string
city: string
state: string
postcode: string
country: string
}
export interface WooOrderCreateLineItem {
product_id: number
variation_id?: number
quantity: number
}
export interface WooOrderCreateShippingLine {
method_id: string
method_title: string
total: string
}
export interface WooOrderCreateRequest {
payment_method: string
payment_method_title: string
set_paid: boolean
billing: WooOrderCreateBilling
shipping: WooOrderCreateShipping
line_items: WooOrderCreateLineItem[]
shipping_lines: WooOrderCreateShippingLine[]
}
// ------------------------------------
// RESPONSE: WooCommerce Order
// ------------------------------------
export interface WooMetaData {
id: number
key: string
value: any
}
export interface WooTaxItem {
id: number
total: string
subtotal: string
}
export interface WooLineItem {
id: number
name: string
product_id: number
variation_id: number
quantity: number
tax_class: string
subtotal: string
subtotal_tax: string
total: string
total_tax: string
taxes: WooTaxItem[]
meta_data: WooMetaData[]
sku: string
price: number
}
export interface WooTaxLine {
id: number
rate_code: string
rate_id: number
label: string
compound: boolean
tax_total: string
shipping_tax_total: string
meta_data: WooMetaData[]
}
export interface WooShippingLine {
id: number
method_title: string
method_id: string
total: string
total_tax: string
taxes: WooTaxItem[]
meta_data: WooMetaData[]
}
export interface WooLink {
href: string
}
export interface WooOrderLinks {
self: WooLink[]
collection: WooLink[]
}
export interface WooOrderCreateResponse {
id: number
parent_id: number
number: string
order_key: string
created_via: string
version: string
status: string
currency: string
date_created: string
date_created_gmt: string
date_modified: string
date_modified_gmt: string
discount_total: string
discount_tax: string
shipping_total: string
shipping_tax: string
cart_tax: string
total: string
total_tax: string
prices_include_tax: boolean
customer_id: number
customer_ip_address: string
customer_user_agent: string
customer_note: string
billing: WooOrderCreateBilling
shipping: WooOrderCreateShipping
payment_method: string
payment_method_title: string
transaction_id: string
date_paid: string | null
date_paid_gmt: string | null
date_completed: string | null
date_completed_gmt: string | null
cart_hash: string
meta_data: WooMetaData[]
line_items: WooLineItem[]
tax_lines: WooTaxLine[]
shipping_lines: WooShippingLine[]
fee_lines: any[]
coupon_lines: any[]
refunds: any[]
_links: WooOrderLinks
}

View File

@@ -0,0 +1,12 @@
export interface YandexLocationVariantDetect {
geo_id: number
address: string
}
export interface YandexLocationDetectResponse {
variants: YandexLocationVariantDetect[]
}
export interface YandexLocationDetectRequest {
location: string
}

127
shared/yandex_pvz.ts Normal file
View File

@@ -0,0 +1,127 @@
// -----------------------------
// REQUEST
// -----------------------------
export type YandexPvzPaymentMethod = 'already_paid'
export interface YandexPvzCoordinateRange {
from: number
to: number
}
export interface YandexPvzPickupServicesRequest {
is_fitting_allowed: boolean
is_partial_refuse_allowed: boolean
is_paperless_pickup_allowed: boolean
is_unboxing_allowed: boolean
}
export interface YandexPvzRequest {
pickup_point_ids?: string[]
geo_id?: number
longitude?: YandexPvzCoordinateRange
latitude?: YandexPvzCoordinateRange
type?: 'pickup_point'
payment_method?: YandexPvzPaymentMethod
available_for_dropoff?: boolean
is_yandex_branded?: boolean
is_not_branded_partner_station?: boolean
is_post_office?: boolean
payment_methods?: YandexPvzPaymentMethod[]
pickup_services?: YandexPvzPickupServicesRequest
}
// -----------------------------
// RESPONSE
// -----------------------------
export interface YandexPvzPosition {
latitude: number
longitude: number
}
export interface YandexPvzAddress {
geoId: string
country: string
region: string
subRegion: string
locality: string
street: string
house: string
housing: string
apartment: string
building: string
comment: string
full_address: string
postal_code: string
}
export interface YandexPvzContact {
first_name: string
last_name: string
patronymic: string
phone: string
email: string
}
export interface YandexPvzTimeObject {
hours: number
minutes: number
}
export interface YandexPvzScheduleRestriction {
days: number[]
time_from: YandexPvzTimeObject
time_to: YandexPvzTimeObject
}
export interface YandexPvzSchedule {
time_zone: number
restrictions: YandexPvzScheduleRestriction[]
}
export interface YandexPvzPickupServices {
is_fitting_allowed: boolean
is_partial_refuse_allowed: boolean
is_paperless_pickup_allowed: boolean
is_unboxing_allowed: boolean
}
export interface YandexPvzDayoff {
date: string
date_utc: string
}
export interface YandexPvzDeactivation {
deactivation_date_predicted_debt: boolean
available_for_dropoff: boolean
available_for_c2c_dropoff: boolean
}
export interface YandexPvzPoint {
ID: string
operator_station_id: string
name: string
type: 'pickup_point'
position: YandexPvzPosition
address: YandexPvzAddress
instruction: string
payment_methods: YandexPvzPaymentMethod[]
contact: YandexPvzContact
schedule: YandexPvzSchedule
is_yandex_branded: boolean
is_market_partner: boolean
is_dark_store: boolean
is_post_office: boolean
pickup_services: YandexPvzPickupServices
deactivation_date: YandexPvzDeactivation
dayoffs: YandexPvzDayoff[]
}
export interface YandexPvzResponse {
points: YandexPvzPoint[]
}