Compare commits

..

94 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
alsaze
436f537166 карты пвз
All checks were successful
Deploy / build (push) Successful in 53s
2025-11-10 18:51:31 +03:00
alsaze
2591d145f8 карты пвз
All checks were successful
Deploy / build (push) Successful in 3m29s
2025-11-10 18:31:46 +03:00
alsaze
bff6833781 карты пвз
All checks were successful
Deploy / build (push) Successful in 2m37s
2025-11-10 16:35:00 +03:00
alsaze
6cd7bd1dec карты пвз
All checks were successful
Deploy / build (push) Successful in 12m27s
2025-10-23 01:03:00 +03:00
alsaze
8e68d5b162 карта ПВЗ
All checks were successful
Deploy / build (push) Successful in 12m45s
2025-10-22 22:57:29 +03:00
alsaze
3119ecc2fa карта ПВЗ
All checks were successful
Deploy / build (push) Successful in 2m10s
2025-10-17 19:15:10 +03:00
alsaze
2b8a5e5774 карта ПВЗ
All checks were successful
Deploy / build (push) Successful in 2m21s
2025-10-17 17:10:14 +03:00
alsaze
887ea75e8b карта ПВЗ
Some checks failed
Deploy / build (push) Failing after 24s
2025-10-17 03:32:57 +03:00
alsaze
c38b6ba6a9 карта ПВЗ
All checks were successful
Deploy / build (push) Successful in 2m24s
2025-10-17 02:44:33 +03:00
alsaze
9f2a6e5dd2 карта ПВЗ
All checks were successful
Deploy / build (push) Successful in 56s
2025-10-15 17:01:37 +03:00
alsaze
9c59939015 карта ПВЗ
All checks were successful
Deploy / build (push) Successful in 52s
2025-10-15 15:30:26 +03:00
alsaze
b06786ad96 карта ПВЗ
All checks were successful
Deploy / build (push) Successful in 2m22s
2025-10-15 15:15:28 +03:00
alsaze
d038b66c94 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 51s
2025-10-10 01:42:07 +03:00
alsaze
6883d1cfbd создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 54s
2025-10-09 18:46:32 +03:00
alsaze
154c7f5ffd создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 2m31s
2025-10-09 15:55:07 +03:00
alsaze
35caed5d80 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 47s
2025-10-09 01:34:46 +03:00
alsaze
bb355fe6af создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 49s
2025-10-09 01:00:16 +03:00
alsaze
01c860ceee создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 48s
2025-10-09 00:44:43 +03:00
alsaze
cd8a5b26ba создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 57s
2025-10-09 00:26:29 +03:00
alsaze
7a7dc0db00 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 49s
2025-10-09 00:16:30 +03:00
alsaze
9d77c12b1d создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 47s
2025-10-09 00:05:27 +03:00
alsaze
123639f573 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 52s
2025-10-08 23:32:28 +03:00
alsaze
8934277a2b создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 49s
2025-10-08 22:21:35 +03:00
alsaze
f495f97352 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 51s
2025-10-08 17:59:26 +03:00
alsaze
cb5b80fc6b создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 48s
2025-10-08 16:06:53 +03:00
alsaze
5416df0c2c создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 46s
2025-10-08 14:47:17 +03:00
alsaze
66dd08cf65 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 2m8s
2025-10-08 14:36:09 +03:00
alsaze
6a08eebf01 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 46s
2025-10-05 18:02:06 +03:00
alsaze
dd7abe1fc6 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 45s
2025-10-05 15:54:16 +03:00
Никита Круглицкий
4c8aebddf6 keys
All checks were successful
Deploy / build (push) Successful in 47s
2025-10-05 18:23:39 +06:00
Никита Круглицкий
3c41b7d9a1 keys
All checks were successful
Deploy / build (push) Successful in 46s
2025-10-05 18:05:02 +06:00
Никита Круглицкий
b5091d2a16 keys
Some checks failed
Deploy / build (push) Failing after 38s
2025-10-05 17:38:55 +06:00
Никита Круглицкий
adc347e86b keys
Some checks failed
Deploy / build (push) Failing after 32s
2025-10-05 17:36:45 +06:00
alsaze
c534729473 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 50s
2025-10-05 14:22:24 +03:00
alsaze
66bda5d0c7 создаю телегу товаров
Some checks failed
Deploy / build (push) Has been cancelled
2025-10-05 14:20:38 +03:00
alsaze
2c80b7095e создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 45s
2025-10-03 20:07:03 +03:00
alsaze
7a7d27c7ae создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 45s
2025-10-03 19:26:39 +03:00
alsaze
bb195777c3 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 47s
2025-10-01 16:35:25 +03:00
alsaze
83d2a56e52 создаю телегу товаров
All checks were successful
Deploy / build (push) Successful in 43s
2025-09-26 23:17:53 +03:00
alsaze
7c811a4841 product-card чтобы запросы с undefined не сыпались
All checks were successful
Deploy / build (push) Successful in 46s
2025-09-26 02:07:26 +03:00
Никита Круглицкий
2b75aaf7bf test ssr
All checks were successful
Deploy / build (push) Successful in 2m20s
2025-09-26 01:32:37 +06:00
Никита Круглицкий
9e07e4917a test ssr
All checks were successful
Deploy / build (push) Successful in 42s
2025-09-26 01:29:11 +06:00
Никита Круглицкий
94a3c04f33 test ssr
All checks were successful
Deploy / build (push) Successful in 1m29s
2025-09-26 01:27:01 +06:00
Никита Круглицкий
f335c90d13 test ssr
Some checks failed
Deploy / build (push) Failing after 13s
2025-09-26 01:25:18 +06:00
Никита Круглицкий
4fcd88e8e5 ci/cd
All checks were successful
Deploy / build (push) Successful in 34s
2025-09-26 01:08:44 +06:00
Никита Круглицкий
062d48a3d7 ci/cd
All checks were successful
Deploy / build (push) Successful in 33s
2025-09-26 01:05:53 +06:00
Никита Круглицкий
18cbfae2d6 ci/cd
All checks were successful
Deploy / build (push) Successful in 30s
2025-09-26 00:58:11 +06:00
Никита Круглицкий
e02ceb9e16 ci/cd
Some checks failed
Deploy / build (push) Failing after 28s
2025-09-26 00:56:56 +06:00
Никита Круглицкий
fb3cb91135 ci/cd
All checks were successful
Deploy / build (push) Successful in 30s
2025-09-26 00:54:56 +06:00
Никита Круглицкий
0543831f28 ci/cd
Some checks failed
Deploy / build (push) Failing after 3m4s
2025-09-26 00:49:04 +06:00
Никита Круглицкий
d8f7153d34 ci/cd
Some checks failed
Deploy / build (push) Failing after 15s
2025-09-26 00:47:53 +06:00
Никита Круглицкий
280a2b20f9 ci/cd
Some checks failed
Deploy / build (push) Failing after 1m30s
2025-09-26 00:46:01 +06:00
Никита Круглицкий
a0f41b5c91 ci/cd
Some checks failed
Deploy / build (push) Failing after 8s
2025-09-26 00:43:46 +06:00
Никита Круглицкий
3121e9aaee ci/cd
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m56s
2025-09-26 00:15:28 +06:00
Никита Круглицкий
0ada372956 ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 14s
2025-09-26 00:14:03 +06:00
Никита Круглицкий
494bc70447 ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 2m27s
2025-09-26 00:10:38 +06:00
Никита Круглицкий
0b05e6c13d ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 1m30s
2025-09-25 23:46:46 +06:00
Никита Круглицкий
f736334e67 ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 7s
2025-09-25 23:46:16 +06:00
Veselov
9b42223a97 product-card
Some checks failed
Deploy / build-and-deploy (push) Failing after 9s
2025-09-21 01:27:48 +03:00
Veselov
e2ebf54d56 product-card 2025-09-16 23:14:47 +03:00
Veselov
2226fb2abe product-card 2025-09-06 22:32:13 +03:00
Veselov
02abf2781e product-card 2025-09-05 17:33:46 +03:00
Veselov
3259495ffb product-card 2025-09-04 20:14:34 +03:00
Veselov
1848d99179 product-card 2025-09-04 20:06:44 +03:00
Veselov
a2ca4ec35e add README.md
Some checks failed
Deploy / build-and-deploy (push) Failing after 6s
2025-08-10 13:46:48 +03:00
Veselov
316d305cba index.vue
Some checks failed
Deploy / build-and-deploy (push) Failing after 7s
2025-08-10 13:43:32 +03:00
Veselov
b4bb0f9036 index.vue
Some checks failed
Deploy / build-and-deploy (push) Failing after 5s
2025-08-04 16:41:09 +03:00
Veselov
e2e031b7c5 add nuxt/ui
Some checks failed
Deploy / build-and-deploy (push) Failing after 7s
2025-08-04 01:24:20 +03:00
Никита Круглицкий
e5d0c2464d ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 5s
2025-07-31 06:01:42 +06:00
Никита Круглицкий
9e6eb9f8b2 ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 5s
2025-07-31 06:00:52 +06:00
Никита Круглицкий
c0fa763ce8 ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 9s
2025-07-31 05:54:24 +06:00
Никита Круглицкий
b78155bb6a ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 5s
2025-07-31 05:53:54 +06:00
Никита Круглицкий
eb1941c16a ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 5s
2025-07-31 05:45:30 +06:00
Никита Круглицкий
b0138067b2 ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 38s
2025-07-31 05:41:50 +06:00
Никита Круглицкий
28f8b038c1 ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 34s
2025-07-31 05:38:40 +06:00
Никита Круглицкий
2d96a0d9e7 ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 40s
2025-07-31 05:22:25 +06:00
Veselov
bce683c0eb from v1 to v3
Some checks failed
Deploy / build-and-deploy (push) Failing after 37s
2025-07-28 18:47:33 +03:00
Veselov
cd32863494 Страница по uid
Some checks failed
Deploy / build-and-deploy (push) Failing after 29s
2025-07-28 17:49:59 +03:00
Veselov
c5603e6789 Merge branch 'from-WP2-to-WC3'
Some checks failed
Deploy / build-and-deploy (push) Failing after 39s
2025-07-28 15:29:38 +03:00
Никита Круглицкий
7baf2fbf0c ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 1m5s
2025-07-24 14:29:23 +06:00
Никита Круглицкий
0905a4662e ci/cd
Some checks failed
Deploy / build-and-deploy (push) Failing after 30s
2025-07-24 14:22:36 +06:00
77 changed files with 32179 additions and 7560 deletions

View File

@ -6,14 +6,35 @@ on:
- master - master
jobs: jobs:
build-and-deploy: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Keyscan
uses: actions/checkout@v4
- name: Install dependencies
run: | run: |
yarn install ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
yarn build
- name: Checkout
uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
ssh-strict: false
persist-credentials: false
- name: Build
run: docker build -t paxton:latest .
- name: Stop old container
run: docker rm -f paxton || true
- name: Run
run: |
docker run -d \
--name paxton \
--network traefik \
--label "traefik.enable=true" \
--label "traefik.http.routers.paxton.rule=Host(\`paxton.koptilnya.xyz\`)" \
--label "traefik.http.routers.paxton.entrypoints=websecure" \
--label "traefik.http.routers.paxton.tls.certresolver=myresolver" \
--label "traefik.http.services.paxton.loadbalancer.server.port=80" \
paxton:latest

28
Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json yarn.lock ./
RUN yarn install
COPY . .
RUN yarn build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/.output/ ./
ENV PORT=80
ENV BSPB_MERCHANT_ID=TT00001
ENV BSPB_MERCHANT_PASSWORD=ztTGre1OBZg3
ENV BSPB_API_URL=https://pgtest.bspb.ru:5443
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
CMD ["node", "/app/server/index.mjs"]

View File

@ -1,75 +1,7 @@
# Nuxt Minimal Starter node.js 20.19.0
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. yarn
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev yarn dev
# bun https://wp.koptilnya.xyz/wp-admin/edit.php?post_type=product
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

12724
api/Api.ts

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
export * from './getProductsDetail' export * from './wp/index'

View File

@ -0,0 +1,4 @@
import api from '~/api/instance'
export const getProductAttributesDetail = async (productId: number) =>
await api.wc.v3ProductsAttributesDetail(productId)

View File

@ -1,4 +1,4 @@
import api from '~/api/instance' import api from '~/api/instance'
export const getProductsDetail = async (productId: number) => export const getProductsDetail = async (productId: number) =>
await api.wc.v1ProductsDetail(productId) await api.wc.v3ProductsDetail(productId)

View File

@ -0,0 +1,4 @@
import api from '~/api/instance'
export const getProductsList = async () =>
await api.wc.v3ProductsList()

View File

@ -0,0 +1,4 @@
import api from '~/api/instance'
export const getProductsVariationsList = async (productId: number) =>
await api.wc.v3ProductsVariationsList(productId, { per_page: 99 })

View File

@ -0,0 +1,4 @@
export * from './getProductAttributesDetail'
export * from './getProductsDetail'
export * from './getProductsList'
export * from './getProductsVariationsList'

View File

@ -1 +1 @@
export * from './useGetProductsDetail' export * from './wp/index'

View File

@ -1,14 +0,0 @@
import { useQuery } from '@tanstack/vue-query'
import { unref, watch } from 'vue'
import { getProductsDetail } from '~/api/endpoints'
export const useGetProductsDetail = (productId: MaybeRef<number>) => {
const q = useQuery({
queryKey: ['get-products-detail', unref(productId)],
queryFn: () => getProductsDetail(unref(productId)),
})
watch(() => productId, () => q.refetch())
return q
}

4
api/queries/wp/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './useGetProductAttributesDetail'
export * from './useGetProductsDetail'
export * from './useGetProductsList'
export * from './useGetProductsVariationsList'

View File

@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/vue-query'
import { unref } from 'vue'
import { getProductAttributesDetail } from '~/api/endpoints/wp'
export const useGetProductAttributesDetail = (productId: MaybeRef<number>) => {
return useQuery({
queryKey: ['get-products-detail', productId],
queryFn: () => getProductAttributesDetail(unref(productId)),
})
}

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/vue-query'
import { unref } from 'vue'
import { getProductsDetail } from '~/api/endpoints/wp'
export const useGetProductsDetail = (productId: MaybeRef<number>) => {
return useQuery({
queryKey: ['get-products-detail', productId],
queryFn: () => getProductsDetail(unref(productId)),
enabled: !!unref(productId),
})
}

View File

@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/vue-query'
import { getProductsList } from '~/api/endpoints/wp/getProductsList'
export const useGetProductsList = () => {
return useQuery({
queryKey: ['get-products-list'],
queryFn: () => getProductsList(),
staleTime: Infinity,
})
}

View File

@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/vue-query'
import { unref } from 'vue'
import { getProductsVariationsList } from '~/api/endpoints/wp'
export const useGetProductsVariationsList = (productId: MaybeRef<number>) => {
return useQuery({
queryKey: ['get-products-variations-list', productId],
queryFn: () => getProductsVariationsList(unref(productId)),
enabled: !!unref(productId),
})
}

View File

@ -1,9 +1,9 @@
<template> <template>
<div> <UApp>
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
</div> </UApp>
</template> </template>
<style lang="scss"> <style lang="scss">

2
assets/css/main.css Normal file
View File

@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@nuxt/ui";

View File

@ -1,6 +0,0 @@
// Breakpoints
$breakpoint-sm: 576px;
$breakpoint-md: 768px;
$breakpoint-lg: 992px;
$breakpoint-xl: 1200px;
$breakpoint-xxl: 1400px;

View File

@ -1,70 +0,0 @@
/* Modern CSS Reset */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Remove default margin and padding */
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
figure,
blockquote,
dl,
dd {
margin: 0;
}
/* Set core body defaults */
body {
min-height: 100vh;
scroll-behavior: smooth;
text-rendering: optimizeSpeed;
line-height: 1.5;
}
/* Remove list styles on ul, ol elements */
ul,
ol {
list-style: none;
}
/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
}
/* Make images easier to work with */
img,
picture {
max-width: 100%;
display: block;
}
/* Inherit fonts for inputs and buttons */
input,
button,
textarea,
select {
font: inherit;
}
/* Remove all animations and transitions for people that prefer not to see them */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@ -1,88 +0,0 @@
// Brand Colors
$brand-primary: #4f46e5; // Основной цвет бренда
$brand-secondary: #10b981; // Вторичный цвет бренда
// Neutral Colors
$neutral-50: #f9fafb; // Самый светлый оттенок
$neutral-100: #f3f4f6; // Светлый фон
$neutral-200: #e5e7eb; // Светлая граница
$neutral-300: #d1d5db; // Светлый текст
$neutral-400: #9ca3af; // Неактивный текст
$neutral-500: #6b7280; // Средний текст
$neutral-600: #4b5563; // Основной текст
$neutral-700: #374151; // Темный текст
$neutral-800: #1f2937; // Очень темный текст
$neutral-900: #111827; // Самый темный оттенок
// Semantic Colors
$text-primary: $neutral-900; // Основной текст
$text-secondary: $neutral-600; // Вторичный текст
$text-muted: $neutral-400; // Неактивный текст
$background-primary: $neutral-50; // Основной фон
$background-secondary: $neutral-100; // Вторичный фон
$border-color: $neutral-200; // Цвет границ
// Status Colors
$success: #10b981; // Успех
$warning: #f59e0b; // Предупреждение
$error: #ef4444; // Ошибка
$info: #3b82f6; // Информация
// Basic Colors
$black: #000000;
$dark-background: #13141c;
$dark-grey: #171821;
$grey-selector: #1b1b24;
$grey-blocks: #1e1f2b;
$grey-text-highlight: #23242c;
$grey-blocks-hover: #262736;
$grey-menu: #292a32;
$grey-stroke: #323442;
$grey-paginator: #323442;
$grey-icons: #5b5e6d;
$grey-text: #7f808b;
$grey-text2: #9A9BAB;
$white: #ffffff;
$greeen: #9fff00;
$greeen-dark: #43611b;
$red: #ff4141;
$red-dark: #3e232c;
$green: #20b26c;
$green-dark: #133324;
$gold: #ffc35c;
// Form Elements
$grey-form: #1c1d26;
$grey-form-stroke: #323442;
$grey-button: #21222d;
$grey-button-2: #32323c;
$grey-button-stroke: #5b5e6d;
// Export colors for JavaScript
:export {
black: $black;
darkBackground: $dark-background;
darkGrey: $dark-grey;
greySelector: $grey-selector;
greyBlocks: $grey-blocks;
greyTextHighlight: $grey-text-highlight;
greyBlocksHover: $grey-blocks-hover;
greyMenu: $grey-menu;
greyStroke: $grey-stroke;
greyPaginator: $grey-paginator;
greyIcons: $grey-icons;
greyText: $grey-text;
white: $white;
greeen: $greeen;
greeenDark: $greeen-dark;
red: $red;
redDark: $red-dark;
green: $green;
greenDark: $green-dark;
greyForm: $grey-form;
greyFormStroke: $grey-form-stroke;
greyButton: $grey-button;
greyButton2: $grey-button-2;
greyButtonStroke: $grey-button-stroke;
gold: $gold;
}

View File

@ -1,12 +0,0 @@
@use 'main' as *;
body {
font-family: 'Inter', sans-serif;
color: $text-color;
margin: 0;
padding: 0;
}
.container {
@include container;
}

View File

@ -1,78 +1,60 @@
@use 'reset'; //скроллбар
@use 'colors' as *; ::-webkit-scrollbar {
@use 'typography' as *; width: 8px;
@use 'utils' as *; }
// Base styles ::-webkit-scrollbar-track {
html { background: transparent;
font-size: 16px; border-radius: 4px;
line-height: 1.5; }
scroll-behavior: smooth;
::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.5);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(128, 128, 128, 0.7);
background-clip: padding-box;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.5) transparent;
} }
body { body {
font-family: $font-family-base; -ms-overflow-style: -ms-autohiding-scrollbar;
color: white;
background-color: black;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
// Typography //swiper
h1, .swiper {
h2, width: 100%;
h3, height: calc(100dvh - 54px);
h4,
h5,
h6 {
font-weight: $font-weight-semibold;
line-height: 100%;
color: #ffffff;
} }
h1 { .swiper-slide {
@include h1('h1'); text-align: center;
font-size: 18px;
background: #444;
/* Center slide text vertically */
display: flex;
justify-content: center;
align-items: center;
} }
h2 { .swiper-slide img {
@include h2('h2'); display: block;
width: 100%;
height: calc(100dvh - 54px);
object-fit: cover;
} }
h3 { .swiper-pagination {
@include h3('h3'); position: absolute;
} bottom: 200px !important;
--swiper-pagination-bullet-size: 4px
a {
color: inherit;
text-decoration: none;
transition: all 0.2s ease;
}
// Buttons
button {
cursor: pointer;
font-family: inherit;
font-size: inherit;
line-height: inherit;
border: none;
background: none;
padding: 0;
}
// Forms
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
border-radius: 0.25rem;
padding: 0.5rem 1rem;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: $brand-primary;
}
} }

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,153 +0,0 @@
@use 'sass:color';
@use 'sass:math';
@use 'breakpoints' as *;
// Mixins
@mixin responsive($breakpoint) {
@media (min-width: $breakpoint) {
@content;
}
}
@mixin mobile {
@media (max-width: $breakpoint-md) {
@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; }

151
components/DeliveryInfo.vue Normal file
View File

@ -0,0 +1,151 @@
<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="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 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

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

103
components/PayBlock.vue Normal file
View File

@ -0,0 +1,103 @@
<template>
<div>
<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>
<div>товары {{ `(${cart?.line_items?.length} шт)` }}</div>
<div v-if="cartSum">
<ProductPrice :is-headline="false" text="итого" :price="cartSum" />
</div>
</template>
<template #footer>
<UButton
class="justify-center text-center w-2xs"
size="xl"
:label="pay ? 'оформить заказ' : 'перейти к оформлению'"
@click="pay ? createOrder() : router.push(`/checkout/delivery`)"
/>
</template>
</UPageCard>
</div>
</template>
<script setup lang="ts">
import type { BsbpCreateResponse } from '#shared/bsbp_create'
import { useMediaQuery } from '@vueuse/core'
defineProps({
pay: {
type: Boolean,
default: false,
},
isSummary: {
type: Boolean,
default: false,
},
})
const router = useRouter()
const isMobile = useMediaQuery('(max-width: 1280px)', {
ssrWidth: 768,
})
const { cart, cartSum } = useCart()
const createOrder = async () => {
const { data } = await useFetch<BsbpCreateResponse>('/api/bsbp_create', {
method: 'POST',
body: {
order: {
typeRid: 'Purchase',
amount: cartSum,
currency: 'RUB',
hppRedirectUrl: `${process.env.VITE_BASE_URL}/cart`,
},
},
})
const redirectUrl = `${data?.value?.order?.hppUrl}?orderId=${data?.value?.order?.id}&password=${data.value?.order?.password}`
window.open(redirectUrl, '_blank')
}
</script>
<style lang="scss">
.pay-block {
&--mobile {
position: sticky;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
}
}
</style>

57
components/PhotoModel.vue Normal file
View File

@ -0,0 +1,57 @@
<template>
<UPopover
:mode="isMobile ? 'click' : 'hover'"
:ui="{ content: '--reka-popper-transform-origin: 50% -20px;' }"
>
<UButton
icon="i-mdi-human-handsdown"
label="фотомодель"
class="model-btn"
/>
<template #content>
<div class="model-info">
<div
v-for="parameter in photoModel"
:key="parameter?.split(': ')[0]"
>
{{ parameter?.split(': ')[0] }} <strong>{{ parameter?.split(': ')[1] }}</strong>
</div>
</div>
</template>
</UPopover>
</template>
<script setup lang="ts">
import { useMediaQuery } from '@vueuse/core'
const { photoModel } = useProduct()
const isMobile = useMediaQuery('(max-width: 1280px)')
</script>
<style scoped lang="scss">
.model-btn {
display: inline-flex !important;
align-self: flex-start;
}
.model-info {
width: 140px;
display: flex;
flex-direction: column;
gap: 3px;
padding: 15px;
font-size: 14px;
line-height: 1.3;
white-space: nowrap;
& > * {
display: flex;
justify-content: space-between;
}
}
.model-info strong {
font-weight: 600;
}
</style>

View File

View File

@ -0,0 +1,125 @@
<template>
<div class="product-description">
<h1>{{ productsData?.name }}</h1>
<ProductPrice :price="currentVariant?.options[0]?.price" />
<ProductVariations v-if="colors?.length > 1" />
<div>
{{ `Цвет: ${t(`colors.${currentColor}`)}` }}
</div>
<div>
{{ `Материал: ${t(`materials.${currentMaterial}`)}` }}
</div>
<div>
Размер
</div>
<div class="product-description__sizes">
<div
v-for="option in currentVariant?.options"
:key="option"
class="product-description__size"
@click="() => currentSize = option"
>
<UButton
block
:label="getAttribute(option?.attributes, 'size')?.option"
:disabled="option?.stock_status === 'outofstock'"
:variant="currentSize === option ? undefined : 'outline'"
/>
</div>
</div>
<UButton
:disabled="!currentSize"
class="justify-content-center"
:label="currentSize ? `Добавить в корзину` : `Выберите размер`"
size="xl"
@click="addToCartBtn()"
/>
<UAccordion :items="items" />
</div>
</template>
<script setup lang="ts">
import ProductPrice from '~/components/ProductPrice.vue'
import ProductVariations from '~/components/ProductVariations.vue'
import { useCurrentProduct, useProduct } from '~/composables'
const { t } = useI18n()
const { cartAddItem } = useCart()
const { productsData, colors, getAttribute } = useProduct()
const { currentVariant, currentColor, currentMaterial } = useCurrentProduct()
const currentSize = ref(undefined)
const items = computed(() => [
{
label: 'Описание товара',
icon: 'i-heroicons-document-text',
content: productsData?.value?.description?.replace(/<\/?p>/g, ''),
},
{
label: 'Состав и параметры',
icon: 'i-heroicons-beaker',
content: 'хлопок 100%',
},
{
label: 'Рекомендации по уходу',
icon: 'i-heroicons-sparkles',
content: 'Бережная стирка при максимальной температуре 30ºС\nНе отбеливать\nМашинная сушка запрещена\nГлажение при 110ºС\nПрофессиональная сухая чистка',
},
{
label: 'Доставка и оплата',
icon: 'i-heroicons-truck',
content: 'Доставка в пункт выдачи',
},
{
label: 'Возврат и обмен',
icon: 'i-heroicons-arrow-path',
content: 'Возврат в течение 14 дней\nОбмен размера в течение 7 дней',
},
])
function addToCartBtn() {
cartAddItem({ variation_id: currentSize?.value?.id })
}
</script>
<style lang="scss">
.product-description {
width: 100%;
padding: 30px 30px 0;
display: flex;
flex-direction: column;
gap: 10px;
@media (min-width: 768px) {
position: sticky;
top: 40px;
max-height: calc(100vh - 40px);
max-width: 350px;
overflow-y: auto;
}
@include mobile {
padding: 20px 30px;
}
&__sizes {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
&__size {
width: 65px;
}
}
</style>

View File

@ -0,0 +1,58 @@
<template>
<div v-if="!isMobile" class="product-images">
<div
v-for="image in currentVariantImages?.slice(0, 5)"
:key="image?.id"
>
<img width="100%" :src="image?.src" :alt="image?.src">
</div>
<div class="product-images__model">
<PhotoModel />
</div>
</div>
<Swiper
v-if="isMobile"
:pagination="true"
:loop="true"
:modules="modules"
class="mySwiper"
>
<SwiperSlide
v-for="image in currentVariantImages?.slice(0, 5)"
:key="image?.src"
>
<img width="100%" :src="image?.src" :alt="image?.src">
</SwiperSlide>
</Swiper>
</template>
<script setup lang="ts">
import { useMediaQuery } from '@vueuse/core'
import { Pagination } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/vue'
import { useCurrentProduct } from '~/composables'
import 'swiper/css'
import 'swiper/css/pagination'
const isMobile = useMediaQuery('(max-width: 1280px)')
const modules = [Pagination]
const { currentVariantImages } = useCurrentProduct()
</script>
<style lang="scss">
.product-images {
position: relative;
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
&__model {
position: fixed;
top: calc(10px + 54px);
right: calc(10px + 350px);
z-index: 1000;
}
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<div class="product-price">
<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>
</template>
<script setup lang="ts">
defineProps({
price: {
type: [String, Number],
required: true,
},
isHeadline: {
type: Boolean,
default: true,
},
text: {
type: String,
default: '',
},
})
</script>
<style lang="scss">
.product-price {
&__text {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<div class="product-variations">
<div
v-for="variation in variations"
:key="variation?.option?.id"
@click="() => currentVariant = variation"
>
<div class="product-variations__variation">
<img width="80" :src="variation?.image[0]?.src" :alt="variation?.image[0]?.src">
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useCurrentProduct, useProduct } from '~/composables'
const { variations } = useProduct()
const { currentVariant } = useCurrentProduct()
</script>
<style lang="scss">
.product-variations {
padding: 10px;
border-radius: 10px;
background: white;
display: flex;
flex-direction: row;
gap: 4px;
&__variation {
img {
border-radius: 10px;
cursor: pointer;
}
}
}
</style>

192
components/PvzMap.vue Normal file
View File

@ -0,0 +1,192 @@
<template>
<div v-if="!hasCoords" class="p-4 text-center">
Определяем ваше местоположение...
</div>
<YandexMap
v-else
v-model="map"
class="w-full"
:settings="{ location }"
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="pickupPoint in pickupPoints"
:key="pickupPoint.id"
position="top-center left-center"
:settings="{ coordinates: [pickupPoint.position.longitude, pickupPoint.position.latitude] }"
@click="onSelectPoint(pickupPoint)"
>
<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>
<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>
</template>
<script setup lang="ts">
import type { PickupPoint } from '#shared/yandex_pvz'
import type { LngLatBounds, YMap } from '@yandex/ymaps3-types'
import type { YMapLocationRequest } from '@yandex/ymaps3-types/imperative/YMap'
import type { YMapClusterer } from '@yandex/ymaps3-types/packages/clusterer'
import type { PropType } from 'vue'
import { IPvzMapFittingTabs, IPvzMapTabs } from '#shared/types'
import { useGeolocation, useMediaQuery } from '@vueuse/core'
import { computed, defineEmits, shallowRef } from 'vue'
import {
YandexMap,
YandexMapClusterer,
YandexMapControl,
YandexMapControls,
YandexMapDefaultFeaturesLayer,
YandexMapDefaultSchemeLayer,
YandexMapMarker,
} from 'vue-yandex-maps'
import MapControlFitting from '~/components/MapControlFitting.vue'
import MapControlTabs from '~/components/MapControlTabs.vue'
defineProps<{ pickupPoints: PickupPoint[] }>()
const emit = defineEmits(['update:checkout-pickup-point'])
const checkoutPickupPoint = defineModel('checkoutPickupPoint', { type: Object as PropType<PickupPoint>, default: () => undefined })
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 isMobile = useMediaQuery('(max-width: 1280px)')
const clusterer = shallowRef<YMapClusterer | null>(null)
const trueBounds = ref<LngLatBounds>([[0, 0], [0, 0]])
const map = shallowRef<null | YMap>(null)
const hasCoords = computed(() => coords.value.latitude !== Infinity && coords.value.longitude !== Infinity)
const location = ref<YMapLocationRequest>({
zoom: 2,
})
const onSelectPoint = (pickupPoint: PickupPoint) => {
checkoutPickupPoint.value = pickupPoint
emit('update:checkout-pickup-point', pickupPoint)
}
watch(coords, (newCoords) => {
if (newCoords && hasCoords.value) {
location.value = {
center: [newCoords.longitude, newCoords.latitude],
zoom: 9,
duration: 2500,
}
}
}, { once: true })
watch(() => checkoutPickupPoint.value, (newPickupPoint) => {
if (!newPickupPoint)
return
location.value = {
center: [newPickupPoint.position.longitude, newPickupPoint.position.latitude],
zoom: 18,
duration: 500,
}
})
</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;
}
&::after {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
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));
}
&__icon {
color: white;
width: 20px;
height: 20px;
}
&:hover &__icon {
color: var(--ui-primary);
transform: scale(1.1);
}
}
.cluster {
background-color: #0f172b;
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;
&:hover {
transform: scale(1.1);
background-color: #1e293b;
}
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<div class="cart-item">
<NuxtLink :to="`/product/${productsData?.parent_id}`">
<img
class="cart-item__image"
:src="productsData?.images[0]?.src"
alt="cart?.image"
>
</NuxtLink>
<div>
{{ productsData?.name }}
<div class="d-flex align-items-center">
{{ productsData?.price }}
<Icon name="ph:currency-rub" />
</div>
<div class="cart-item__attributes">
<div>{{ t(`colors.${color}`) }} {{ t(`materials.${material}`) }}</div>
<div>{{ `размер ${size}` }}</div>
</div>
<div>
<UButton
icon="i-lucide-trash"
@click="deleteItem(cartItem)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
cartItem: {
type: String,
required: true,
},
})
const { t } = useI18n()
const { cartRemoveItem } = useCart()
const { productsData, color, material, size } = useProduct(props?.cartItem?.variation_id)
const deleteItem = (item) => {
cartRemoveItem({ variation_id: item?.variation_id })
}
</script>
<style lang="scss">
.cart-item {
max-width: 600px;
display: flex;
flex-direction: row;
gap: 20px;
&__image {
width: 120px;
cursor: pointer;
}
&__attributes {
display: flex;
flex-direction: column;
justify-content: space-between;
}
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<div class="summary-cart-item">
<img
class="summary-cart-item__image"
:src="productsData?.images[0]?.src"
alt="cart?.image"
>
<div class="summary-cart-item__attributes">
{{ size }}
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
summaryCartItem: {
type: String,
required: true,
},
})
const { productsData, size } = useProduct(props?.summaryCartItem?.variation_id)
</script>
<style lang="scss">
.summary-cart-item {
max-width: 600px;
display: flex;
flex-direction: column;
gap: 4px;
&__image {
width: 120px;
}
&__attributes {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
}
</style>

4
composables/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './useCart'
export * from './useCurrentProduct'
export * from './useProduct'
export * from './useProductsList'

49
composables/useCart.ts Normal file
View File

@ -0,0 +1,49 @@
import { useStorage } from '@vueuse/core'
import { useProduct } from './useProduct'
export interface ICartItem {
variation_id: number
}
export interface ICart {
line_items: ICartItem[]
}
export const useCart = () => {
const cart = useStorage<ICart>('cart', { line_items: [] })
const cartAddItem = (item: ICartItem) => {
cart.value.line_items.push(item)
}
const cartRemoveItem = (item: ICartItem) => {
cart?.value?.line_items
?.splice(cart?.value?.line_items
?.findIndex((cartItem: ICartItem) =>
cartItem?.variation_id === item?.variation_id), 1)
}
const cartRemoveAllItems = () => {
cart.value = { line_items: [] }
}
const cartProducts = computed(() => cart?.value?.line_items?.map((line_item) => {
const { productsData } = useProduct(line_item?.variation_id.toString())
return productsData
}))
const cartSum = computed(() => cartProducts?.value?.reduce((acc, curr) => {
acc += Number(curr?.value?.price)
return acc
}, 0))
return {
cart,
cartAddItem,
cartRemoveItem,
cartRemoveAllItems,
cartProducts,
cartSum,
}
}

View File

@ -0,0 +1,98 @@
import type { PickupPoint } from '#shared/yandex_pvz'
import { createSharedComposable, useMediaQuery, useStorage } from '@vueuse/core'
export const useCheckout = createSharedComposable(() => {
const router = useRouter()
const route = useRoute()
const isMobile = useMediaQuery('(max-width: 1280px)')
const checkoutPickupPoint = useStorage<PickupPoint | undefined>(
'checkout-pickupPoint',
undefined,
undefined,
{
serializer: {
read: (v: string) => v ? JSON.parse(v) : undefined,
write: (v: PickupPoint | undefined) => JSON.stringify(v),
},
},
)
const setCheckoutPickupPoint = (point: PickupPoint | undefined) => {
checkoutPickupPoint.value = point
}
const isPickupPointSelected = computed(() => !!checkoutPickupPoint.value)
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 = [
{
step: 1,
title: isMobile.value ? 'mobileDelivery' : 'delivery',
route: 'delivery',
},
{
step: 2,
title: isMobile.value ? 'mobileContacts' : 'contacts',
route: 'contacts',
},
{
step: 3,
title: isMobile.value ? 'mobileSummary' : 'summary',
route: 'summary',
},
]
const currentCheckoutStep
= ref(checkoutSteps.find(value => value.route === route.path.split('/').pop()) || checkoutSteps[0])
function previewStep() {
if (isPickupPointSelected.value && !isMobile.value) {
setCheckoutPickupPoint(undefined)
return
}
const findIndex = checkoutSteps.findIndex(value => value.step === currentCheckoutStep.value.step)
if (findIndex !== 0) {
currentCheckoutStep.value = checkoutSteps[findIndex - 1]
router.push(`/checkout/${currentCheckoutStep?.value.route}`)
}
else {
router.push(`/cart`)
}
}
function nextStep() {
const findIndex = checkoutSteps.findIndex(value => value.step === currentCheckoutStep.value.step)
if (findIndex + 1 !== checkoutSteps.length) {
currentCheckoutStep.value = checkoutSteps[findIndex + 1]
router.push(`/checkout/${currentCheckoutStep?.value.route}`)
}
}
function setStep(pathName: string) {
currentCheckoutStep.value = checkoutSteps.find(value => value.route === pathName) || checkoutSteps[0]
router.push(`/checkout/${pathName}`)
}
return {
isPickupPointSelected,
checkoutPickupPoint,
setCheckoutPickupPoint,
checkoutContacts,
setCheckoutContacts,
checkoutSteps,
currentCheckoutStep,
previewStep,
nextStep,
setStep,
}
})

View File

@ -0,0 +1,28 @@
import { useProduct } from '#build/imports'
import { createSharedComposable } from '@vueuse/core'
import { computed, ref } from 'vue'
export const useCurrentProduct = createSharedComposable(() => {
const { variations, productsData } = useProduct()
const currentVariant = ref(variations?.value[0])
const currentColor = computed(() => currentVariant?.value?.identifier?.split('_')[0])
const currentMaterial = computed(() => currentVariant?.value?.identifier?.split('_')[1])
const currentVariantImages = computed(() =>
productsData?.value?.images?.filter(img => img?.src?.includes(`${currentVariant?.value?.identifier}_`)))
watch(() => variations.value, (newValue) => {
if (newValue) {
currentVariant.value = newValue[0]
}
})
return {
currentColor,
currentMaterial,
currentVariant,
currentVariantImages,
}
})

80
composables/useProduct.ts Normal file
View File

@ -0,0 +1,80 @@
import { useGetProductsDetail, useGetProductsVariationsList } from '~/api/queries/wp'
export const useProduct = (variantId?: string) => {
const route = useRoute()
const currentId = ref<number>(route.params.id ?? variantId)
const { data: productsData } = useGetProductsDetail(currentId)
const { data: productsVariationsData } = useGetProductsVariationsList(currentId)
function getAttribute(attributes: string[], name: string) {
return attributes?.find(attribute => attribute?.name === name)
}
const defaultColor = computed(() => getAttribute(productsData?.value?.default_attributes, 'color')?.option)
const defaultMaterial = computed(() => getAttribute(productsData?.value?.default_attributes, 'material')?.option)
const defaultSize = computed(() => getAttribute(productsData?.value?.default_attributes, 'size')?.option)
const defaultVariant = computed(() => {
return productsVariationsData?.value?.find(variation => getAttribute(variation?.attributes, 'color')?.option === defaultColor?.value)
&& productsVariationsData?.value?.find(variation => getAttribute(variation?.attributes, 'material')?.option === defaultMaterial?.value)
})
const colors = computed(() => getAttribute(productsData?.value?.attributes, 'color')?.options)
const materials = computed(() => getAttribute(productsData?.value?.attributes, 'material')?.options)
const sizes = computed(() => getAttribute(productsData?.value?.attributes, 'size')?.options)
const color = computed(() => getAttribute(productsData?.value?.attributes, 'color')?.option)
const material = computed(() => getAttribute(productsData?.value?.attributes, 'material')?.option)
const size = computed(() => getAttribute(productsData?.value?.attributes, 'size')?.option)
const photoModel = computed(() => productsData?.value?.meta_data?.find(meta => meta?.key === 'photoModel').value?.split(','))
function getIdentifier(productVariant) {
const color = getAttribute(productVariant?.attributes, 'color')?.option
const material = getAttribute(productVariant?.attributes, 'material')?.option
return `${color}_${material}`
}
const variations = computed(() =>
colors?.value?.map((color) => {
if (!productsVariationsData?.value) {
return []
}
const productAsColor = productsVariationsData?.value?.filter(variant => getAttribute(variant?.attributes, 'color')?.option === color)
const identifier = getIdentifier(productAsColor[0])
return {
identifier,
image: productsData?.value?.images?.filter(img => img?.src?.includes(`${identifier}_`)),
options: productAsColor,
}
}) ?? [])
return {
productsData,
productsVariationsData,
defaultColor,
defaultMaterial,
defaultSize,
defaultVariant,
colors,
materials,
sizes,
color,
material,
size,
photoModel,
variations,
getAttribute,
getIdentifier,
}
}

View File

@ -0,0 +1,26 @@
import { computed } from 'vue'
import { useGetProductsList } from '~/api/queries/wp'
import { useProduct } from '~/composables'
export const useProductsList = () => {
const { getAttribute } = useProduct()
// TODO перенести запрос на сервер, на сервере получать id вариантов и делать запросы у useProduct(id),
// получать варианты и вместе со всеми вариантами ренедрить список товаров
const { data: productData } = useGetProductsList()
const productCardData = computed(() =>
productData?.value?.map(product => ({
id: product?.id,
name: product?.name,
price: product?.price,
variations: product?.variations,
images: product?.images?.slice(0, 5),
colors: getAttribute(product?.attributes, 'color')?.options,
})) ?? [],
)
return {
productCardData,
}
}

5
i18n/locales/en.json Normal file
View File

@ -0,0 +1,5 @@
{
"colors": {
"black": "black"
}
}

20
i18n/locales/ru.json Normal file
View File

@ -0,0 +1,20 @@
{
"colors": {
"black": "черный",
"beige": "бежевый",
"grey": "серый",
"grey-black": "серо-черный"
},
"materials": {
"cotton": "хлопок",
"cotton-polyester": "хлопок-полиэстер"
},
"checkoutSteps": {
"delivery": "Выберите адрес получения заказа",
"mobileDelivery": "доставка",
"contacts": "Введите данные получателя",
"mobileContacts": "контакты",
"summary": "Подтвердите заказ",
"mobileSummary": "заказ"
}
}

98
layouts/checkout.vue Normal file
View File

@ -0,0 +1,98 @@
<template>
<div class="layout">
<header class="header">
<div class="header__container">
<Icon class="cursor-pointer w-6 h-6" name="lucide:arrow-left" @click="previewStep" />
<h3>
Шаг {{ currentCheckoutStep?.step }} из {{ checkoutSteps?.length }}
&nbsp;&bull;&nbsp;
{{ t(`checkoutSteps.${currentCheckoutStep?.title}`) }}
</h3>
</div>
</header>
<main class="main" :style="showFooter ? 'margin-bottom: 54px' : 'margin-bottom: 0'">
<UContainer class="container">
<slot />
</UContainer>
</main>
<footer class="footer" :class="{ 'footer--hidden': !showFooter }" />
</div>
</template>
<script setup lang="ts">
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()
</script>
<style lang="scss" scoped>
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.container {
--ui-container: 100%;
max-width: 100%;
margin: 0;
padding: 0;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: 54px;
&__container {
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
padding: 0 16px;
}
h3 {
flex: auto;
}
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
height: 70px;
background-color: #0f172b;
&--hidden {
display: none;
}
}
.main {
flex: 1;
margin-top: 54px;
margin-bottom: 64px;
&__hide-footer {
margin-bottom: 0 !important;
}
}
</style>

View File

@ -1,41 +1,65 @@
<template> <template>
<div class="layout"> <div class="layout">
<header class="header"> <header class="header">
Header <div class="header__container">
<Icon name="lucide:menu" class="cursor-pointer ml20 w-6 h-6 text-gray-700" />
<h1
class="header__headline"
@click="router.push(`/`)"
>
PAXTON
</h1>
<div
class="header__cart"
@click="router.push(`/cart`)"
>
<Icon
name="lucide:shopping-cart"
class="cursor-pointer w-6 h-6 text-gray-700"
/>
<div v-if="cart?.line_items?.length" class="header__cart-count">
{{ cart.line_items.length }}
</div>
</div>
</div>
</header> </header>
<main class="main"> <main class="main">
<div class="container"> <UContainer class="container">
<slot /> <slot />
</div> </UContainer>
</main> </main>
<footer class="footer"> <footer class="footer" :class="{ 'footer--hidden': !(route.path === '/cart' && isMobile) }" />
Footer
</footer>
</div> </div>
</template> </template>
<style lang="scss" scoped> <script setup lang="ts">
@use '@/assets/scss/colors.scss' as colors; import { useMediaQuery } from '@vueuse/core'
@use '@/assets/scss/main.scss' as main;
@use '@/assets/scss/utils.scss' as utils;
const route = useRoute()
const router = useRouter()
const { cart } = useCart()
const isMobile = useMediaQuery('(max-width: 1280px)', {
ssrWidth: 768,
})
</script>
<style lang="scss" scoped>
.layout { .layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
background: colors.$dark-grey;
} }
.container { .container {
max-width: 1000px; --ui-container: 100%;
margin: 48px auto 100px; max-width: 100%;
margin: 0;
@media (max-width: 1280px) { padding: 0;
margin: 24px auto 52px;
padding: 0 24px;
}
} }
.header { .header {
@ -45,14 +69,64 @@
right: 0; right: 0;
z-index: 100; z-index: 100;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: 54px;
background: white;
&__container {
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
text-align: center;
padding: 0 16px;
}
&__headline {
font-size: 32px;
color: black;
cursor: pointer;
}
&__cart {
position: relative;
display: inline-block;
}
&__cart-count {
cursor: pointer;
position: absolute;
top: -6px;
right: -6px;
background: red;
color: white;
font-size: 12px;
font-weight: bold;
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
}
.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: 64px; margin-block: 54px 64px;
}
.footer {
margin-top: auto;
} }
</style> </style>

View File

@ -1,6 +1,59 @@
// 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,
compatibilityDate: '2025-05-15', compatibilityDate: '2025-05-15',
devtools: { enabled: true }, devtools: { enabled: true },
modules: ['@nuxt/ui', '@nuxt/image', '@nuxt/icon', '@nuxt/fonts'], modules: [
'@nuxt/ui',
'@nuxt/image',
'@nuxt/icon',
'@nuxt/fonts',
'@nuxtjs/i18n',
[
'@vee-validate/nuxt',
{
autoImports: true,
},
],
'vue-yandex-maps/nuxt',
],
yandexMaps: {
apikey: '13f4c06b-cb7e-4eeb-81f1-af52f12587b2', // неправильно, через .env ошибка в проде РАЗОБРАТСЯ
},
css: ['~/assets/css/main.css', '~/assets/scss/main.scss'],
i18n: {
locales: [
{ code: 'en', name: 'English', file: 'en.json' },
{ code: 'ru', name: 'Русский', file: 'ru.json' },
],
defaultLocale: 'ru',
strategy: 'prefix_except_default',
detectBrowserLanguage: false,
},
app: {
head: {
meta: [
{
name: 'viewport',
content: 'width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, user-scalable=no',
},
],
},
},
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "${resolve('./assets/scss/mixins')}" as *;`,
},
},
},
},
fonts: {
provider: 'google',
},
}) })

16419
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -13,18 +13,26 @@
"typegen": "node .typegen" "typegen": "node .typegen"
}, },
"dependencies": { "dependencies": {
"@nuxt/content": "^3.7.1",
"@nuxt/fonts": "0.11.4", "@nuxt/fonts": "0.11.4",
"@nuxt/icon": "1.15.0", "@nuxt/icon": "1.15.0",
"@nuxt/image": "1.10.0", "@nuxt/image": "1.10.0",
"@nuxt/ui": "3.2.0", "@nuxt/ui": "^4.0.1",
"@nuxtjs/i18n": "^10.0.4",
"@tanstack/vue-query": "^5.75.5", "@tanstack/vue-query": "^5.75.5",
"@tanstack/vue-query-devtools": "^5.87.1",
"@vee-validate/nuxt": "^4.15.1",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"axios": "^1.12.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"decimal.js": "^10.5.0", "decimal.js": "^10.5.0",
"nuxt": "^3.17.6", "maska": "^3.2.0",
"nuxt": "^4.1.3",
"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",

86
pages/cart.vue Normal file
View File

@ -0,0 +1,86 @@
<template>
<div class="cart">
<div v-if="cart.line_items.length > 0" class="cart__items">
<div
v-for="cartItem in cart?.line_items"
:key="cartItem.variation_id"
>
<CartItem :cart-item="cartItem" />
</div>
</div>
<div v-else>
Корзина пока что пуста
</div>
<ClientOnly>
<PayBlock :is-summary="true" />
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { WooOrderCreateResponse } from '#shared/woo_orders_create'
import { useCart } from '~/composables'
import PayBlock from '../components/PayBlock.vue'
const route = useRoute()
const { cart, cartRemoveAllItems } = useCart()
const { checkoutContacts, checkoutPickupPoint } = useCheckout()
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
// Зарпос должен быть в админке bsbp, ждём доступов. Текущее решение просто создает order в WooCommerce
onMounted(async () => {
if (!route?.query?.ID || cart.value.line_items.length === 0)
return
await wait(5000)
await useFetch<WooOrderCreateResponse>('/api/woo_orders_create', {
method: 'POST',
body: {
payment_method: 'bacs',
payment_method_title: 'Оплата по реквизитам',
set_paid: true,
billing: {
first_name: checkoutContacts?.value?.name,
last_name: checkoutContacts?.value?.surname,
phone: checkoutContacts?.value?.phone,
email: checkoutContacts?.value?.email,
address_1: checkoutPickupPoint.value?.address?.full_address,
postcode: checkoutPickupPoint.value?.address?.postal_code,
city: checkoutPickupPoint?.value?.address?.locality,
country: 'RU',
},
transaction_id: route?.query?.ID,
line_items: cart.value.line_items,
status: 'processing',
},
})
cartRemoveAllItems()
})
</script>
<style lang="scss">
.cart {
margin-top: 120px;
margin-inline: auto;
max-width: 1200px;
display: flex;
flex-direction: row;
justify-content: space-between;
@include mobile {
margin-top: 20px;
flex-direction: column;
padding-inline: 16px;
}
&__items {
display: flex;
flex-direction: column;
gap: 16px;
}
}
</style>

213
pages/checkout/contacts.vue Normal file
View File

@ -0,0 +1,213 @@
<template>
<form v-if="checkoutContacts" class="contacts" @submit.prevent="onSubmit">
<div>
<UInput
id="name"
v-model="name"
v-bind="nameAttrs"
name="name"
size="xl"
placeholder=" "
:ui="{ base: 'peer' }"
:invalid="Boolean(errors.name)"
:help="errors.name"
class="relative w-100"
:color="errors.name ? 'error' : 'neutral'"
highlight
autofocus
>
<label
class="pointer-events-none absolute left-3 text-base transition-all peer-focus:-top-3 peer-focus:text-highlighted peer-focus:text-sm peer-focus:font-medium" :class="[
name
? '-top-3 text-sm text-highlighted font-medium'
: 'top-3 text-dimmed peer-placeholder-shown:top-2 peer-placeholder-shown:text-base peer-placeholder-shown:text-dimmed',
]"
>
<span class="inline-flex bg-default px-1">Имя</span>
</label>
</UInput>
<div v-if="errors?.name" style="color: #ff6467">
{{ errors?.name }}
</div>
</div>
<div>
<UInput
id="surname"
v-model="surname"
v-bind="surnameAttrs"
name="surname"
size="xl"
placeholder=" "
:ui="{ base: 'peer' }"
:invalid="Boolean(errors.surname)"
:help="errors.surname"
class="relative w-100"
:color="errors.surname ? 'error' : 'neutral'"
highlight
>
<label
class="pointer-events-none absolute left-3 text-base transition-all peer-focus:-top-3 peer-focus:text-highlighted peer-focus:text-sm peer-focus:font-medium" :class="[
surname
? '-top-3 text-sm text-highlighted font-medium'
: 'top-3 text-dimmed peer-placeholder-shown:top-2 peer-placeholder-shown:text-base peer-placeholder-shown:text-dimmed',
]"
>
<span class="inline-flex bg-default px-1">Фамилия</span>
</label>
</UInput>
<div v-if="errors?.surname" style="color: #ff6467">
{{ errors?.surname }}
</div>
</div>
<div>
<UInput
id="phone"
v-model="phone"
v-bind="phoneAttrs"
v-maska="'+7 (###) ###-##-##'"
name="phone"
size="xl"
placeholder=" "
:ui="{ base: 'peer' }"
:invalid="Boolean(errors.phone)"
:help="errors.phone"
class="relative w-100"
:color="errors.phone ? 'error' : 'neutral'"
highlight
>
<label
class="pointer-events-none absolute left-3 text-base transition-all peer-focus:-top-3 peer-focus:text-highlighted peer-focus:text-sm peer-focus:font-medium" :class="[
phone
? '-top-3 text-sm text-highlighted font-medium'
: 'top-3 text-dimmed peer-placeholder-shown:top-2 peer-placeholder-shown:text-base peer-placeholder-shown:text-dimmed',
]"
>
<span class="inline-flex bg-default px-1">телефон</span>
</label>
</UInput>
<div v-if="errors?.phone" style="color: #ff6467">
{{ errors?.phone }}
</div>
</div>
<div>
<UInput
id="email"
v-model="email"
v-bind="emailAttrs"
name="email"
size="xl"
placeholder=" "
:ui="{ base: 'peer' }"
class="relative w-100"
trailing-icon="i-lucide-at-sign"
:invalid="Boolean(errors.email)"
:help="errors.email"
:color="errors.email ? 'error' : 'neutral'"
highlight
>
<label
class="pointer-events-none absolute left-3 text-base transition-all peer-focus:-top-3 peer-focus:text-highlighted peer-focus:text-sm peer-focus:font-medium" :class="[
email
? '-top-3 text-sm text-highlighted font-medium'
: 'top-3 text-dimmed peer-placeholder-shown:top-2 peer-placeholder-shown:text-base peer-placeholder-shown:text-dimmed',
]"
>
<span class="inline-flex bg-default px-1">email</span>
</label>
</UInput>
<div v-if="errors?.email" style="color: #ff6467">
{{ errors?.email }}
</div>
</div>
<UButton
size="xl"
label="продолжить"
class="justify-center text-center"
type="submit"
:disabled="errors && Object.keys(errors).length > 0"
/>
</form>
</template>
<script lang="ts" setup>
import { useCheckout } from '~/composables/useCheckout'
const { checkoutContacts, setCheckoutContacts, nextStep } = useCheckout()
const { errors, handleSubmit, defineField } = useForm({
initialValues: {
name: checkoutContacts.value.name,
surname: checkoutContacts.value.surname,
phone: checkoutContacts.value.phone,
email: checkoutContacts.value.email,
},
validationSchema: {
name(value: string) {
if (!value)
return 'Укажите имя'
if (value.trim().length < 2)
return 'Минимум 2 символа'
return true
},
surname(value: string) {
if (!value)
return 'Укажите фамилию'
if (value.trim().length < 2)
return 'Минимум 2 символа'
return true
},
phone(value: string) {
if (!value)
return 'Укажите телефон'
const digits = (value || '').replace(/\D/g, '')
if (digits.length < 11)
return 'Введите корректный телефон'
return true
},
email(value: string) {
if (!value)
return 'Укажите email'
const re = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/
if (!re.test(value))
return 'Введите корректный email'
return true
},
},
})
const [name, nameAttrs] = defineField('name')
const [surname, surnameAttrs] = defineField('surname')
const [phone, phoneAttrs] = defineField('phone')
const [email, emailAttrs] = defineField('email')
const onSubmit = handleSubmit((values) => {
setCheckoutContacts(values)
nextStep()
})
definePageMeta({
layout: 'checkout',
})
</script>
<style lang="scss">
.contacts {
margin-inline: auto;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 16px;
@include mobile {
padding-inline: 10px;
}
}
</style>

194
pages/checkout/delivery.vue Normal file
View File

@ -0,0 +1,194 @@
<template>
<div v-if="coords" class="delivery">
<!-- Desktop -->
<div v-if="!isMobile" class="delivery__sidebar">
<DeliveryInfo v-if="isPickupPointSelected" />
<DeliverySearch
v-else
v-model:checkout-pickup-point="checkoutPickupPoint"
v-model:search-term="searchTerm"
:filtered-points="filteredPoints"
@update:checkout-pickup-point="updatePoint()"
/>
</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
v-model:checkout-pickup-point="checkoutPickupPoint"
v-model:active-tab="activeTab"
v-model:fitting="fitting"
:pickup-points="filteredPoints"
@update:checkout-pickup-point="updatePoint()"
/>
</div>
</template>
<script setup lang="ts">
import type { YandexLocationDetectResponse } from '#shared/yandex_location_detect'
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 DeliveryInfo from '~/components/DeliveryInfo.vue'
import DeliverySearch from '~/components/DeliverySearch.vue'
import PvzMap from '~/components/PvzMap.vue'
import { useCheckout } from '~/composables/useCheckout'
const { setCheckoutPickupPoint, isPickupPointSelected, checkoutPickupPoint } = useCheckout()
const { coords } = useGeolocation()
const open = ref(false)
const isMobile = useMediaQuery('(max-width: 1280px)')
const yandexPvz = ref<YandexPvzResponse>()
const searchTerm = ref('')
const activeTab = ref()
const fitting = ref(IPvzMapFittingTabs.ALL)
const openDeliverySearch = 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()
// получение geo_id из названию города
const { data: yandexLocation } = await useFetch<YandexLocationDetectResponse>('/api/yandex_location_detect', {
method: 'POST',
body: {
location: openstreetmap?.address?.city,
},
})
// получения пунктов выдачи города из geo_id
const { data: yandexPvzApi } = await useFetch<YandexPvzResponse>('/api/yandex_pvz', {
method: 'POST',
body: {
geo_id: yandexLocation?.value?.variants[0]?.geo_id,
},
})
yandexPvz.value = yandexPvzApi.value
})
const filteredPoints = computed<YandexPvzPoint[]>(() => {
const points = yandexPvz.value?.points || []
const term = searchTerm.value?.toLowerCase() || ''
return points.filter((point) => {
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({
layout: 'checkout',
})
</script>
<style lang="scss">
.delivery {
display: flex;
flex-direction: row;
&__sidebar {
flex-shrink: 0;
max-height: calc(100vh - 40px);
overflow-y: auto;
width: 410px;
padding-inline: 24px;
}
}
</style>

108
pages/checkout/summary.vue Normal file
View File

@ -0,0 +1,108 @@
<template>
<div class="summary">
<div class="summary__info">
<h3>в заказе &nbsp;&bull;&nbsp; {{ cart?.line_items?.length }} шт</h3>
<div class="summary__items">
<div
v-for="cartItem in cart?.line_items"
:key="cartItem.variation_id"
>
<SummaryCartItem :summary-cart-item="cartItem" />
</div>
</div>
<div class="space-y-2">
<h3 class="d-flex gap-1 text-center align-items-center">
Yandex
<UButton
size="sm"
class="text-muted-foreground mla cursor-pointer"
variant="ghost"
icon="i-ph-pencil-simple-line"
@click="setStep('delivery')"
/>
</h3>
<DeliveryInfo :show-full-content="false" />
</div>
<div class="space-y-2">
<h3 class="d-flex gap-1 text-center align-items-center">
Получатель
<UButton
size="sm"
class="text-muted-foreground mla cursor-pointer"
variant="ghost"
icon="i-ph-pencil-simple-line"
@click="setStep('contacts')"
/>
</h3>
<div class="flex items-center gap-2">
<UIcon name="i-ph-user" class="text-muted-foreground" />
<span>{{ checkoutContacts?.name }} {{ checkoutContacts?.surname }}</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-ph-envelope-simple" class="text-muted-foreground" />
<span>{{ checkoutContacts?.email }}</span>
</div>
<div class="flex items-center gap-2">
<UIcon name="i-ph-phone" class="text-muted-foreground" />
<span>{{ checkoutContacts?.phone }}</span>
</div>
</div>
</div>
<ClientOnly>
<PayBlock pay :is-summary="true" />
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import SummaryCartItem from '../../components/cart/SummaryCartItem.vue'
import PayBlock from '../../components/PayBlock.vue'
const { cart } = useCart()
const { checkoutContacts, setStep } = useCheckout()
definePageMeta({
layout: 'checkout',
})
</script>
<style lang="scss">
.summary {
max-width: 1200px;
margin-inline: auto;
padding-inline: 16px;
display: flex;
flex-direction: row;
justify-content: space-between;
@include mobile {
flex-direction: column;
}
&__info {
display: flex;
flex-direction: column;
gap: 16px;
@include mobile {
margin-bottom: 26px;
}
}
&__items {
display: flex;
flex-direction: row;
gap: 4px;
}
}
</style>

View File

@ -1,20 +1,79 @@
<template> <template>
<div> <div class="index">
<pre> <div v-if="productCardData.length" class="cards-list">
{{ productsData?.images?.filter(img => img?.src?.includes(colorVariants.blackCottonPolyester)) }} <div
</pre> v-for="product in pizda"
:key="product.id"
class="card"
@click="router.push(`/product/${product.id}`)"
>
<img class="card__image" :src="product?.images?.[0]?.src" alt="card?.image">
<div class="card__description">
<div>{{ product?.name }}</div>
<div class="d-flex align-items-center">
{{ product?.price }}
<Icon name="ph:currency-rub" />
</div>
<div v-if="product?.colors?.length > 1">
{{ `+${product?.colors?.length} Цвета` }}
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useGetProductsDetail } from '~/api/queries' import { useProductsList } from '~/composables'
const { data: productsData } = useGetProductsDetail(79) const { productCardData } = useProductsList()
const colorVariants = { // Карточка вариативного товара с разными цветами в процессе разработки, поэтому отображаем только карточку товара с одним цветов
beigeCottonPolyester: 'beige_cotton-polyester_', const pizda = computed(() => {
blackCotton: 'black_cotton_', 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 === 'Пальто мужское')]
greyCottonPolyester: 'grey_cotton-polyester_', })
blackCottonPolyester: 'black_cotton-polyester_', const router = useRouter()
}
</script> </script>
<style lang="scss">
.cards-list {
padding: 15px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 30px 4px;
@include mobile {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
padding: 10px;
}
}
.card {
display: flex;
flex-direction: column;
cursor: pointer;
@include mobile {
min-width: auto;
}
&__image {
width: 100%;
height: auto;
}
&__description {
padding: 10px 5px;
@include mobile {
padding: 8px 4px;
font-size: 14px;
}
}
}
</style>

134
pages/product/[id].vue Normal file
View File

@ -0,0 +1,134 @@
<template>
<div ref="target" class="product">
<ProductImages v-if="isMobile" />
<div v-if="isMobile" class="product-info">
<div class="product-info__title">
<ProductPrice :price="currentVariant?.options[0]?.price || 0" />
<PhotoModel />
</div>
<h1>
{{ productsData?.name }}
</h1>
<div class="mt20">
<UButton
icon="lucide:shopping-cart"
class="justify-content-center w-100 d-flex"
label="Добавить"
size="xl"
@click="open = !open"
/>
</div>
</div>
<UDrawer
v-if="isMobile"
v-model:open="open"
class="product__drawer"
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 ref="targetDrawer" style="overflow-y: auto;">
<ProductDescription />
</div>
</template>
</UDrawer>
<ProductImages v-if="!isMobile" />
<ProductDescription v-if="!isMobile" />
</div>
</template>
<script setup lang="ts">
import type { UseSwipeDirection } from '@vueuse/core'
import { useMediaQuery, useScroll, useSwipe } from '@vueuse/core'
import { computed, shallowRef } from 'vue'
import ProductPrice from '~/components/ProductPrice.vue'
import { useCurrentProduct, useProduct } from '~/composables'
const { productsData } = useProduct()
const { currentVariant } = useCurrentProduct()
const isMobile = useMediaQuery('(max-width: 1280px)')
const open = ref(false)
const target = shallowRef<HTMLElement | null>(null)
const targetHeight = computed(() => target.value?.offsetHeight)
const targetDrawer = shallowRef<HTMLElement | null>(null)
const targetDrawerHeight = computed(() => targetDrawer.value?.offsetHeight)
const { y } = useScroll(targetDrawer)
const { lengthY } = useSwipe(
target,
{
passive: false,
onSwipeEnd(e: TouchEvent, direction: UseSwipeDirection) {
if (lengthY.value > 0 && targetHeight.value && (Math.abs(lengthY.value) / targetHeight.value) >= 0.1) {
open.value = true
}
},
},
)
const { lengthY: drawerLengthY } = useSwipe(
targetDrawer,
{
passive: false,
onSwipeEnd(e: TouchEvent, direction: UseSwipeDirection) {
if (drawerLengthY.value < 0 && y.value === 0 && targetDrawerHeight.value && (Math.abs(drawerLengthY.value) / targetDrawerHeight.value) >= 0.2) {
open.value = false
}
},
},
)
onMounted(() => {
if (isMobile.value) {
document.body.style.overflow = 'hidden'
}
})
onUnmounted(() => {
if (isMobile.value) {
document.body.style.overflow = ''
}
})
</script>
<style lang="scss">
.product {
position: relative;
width: 100%;
display: flex;
flex-direction: row;
@media (max-width: 768px) {
flex-direction: column;
align-items: center;
}
}
.product-info {
padding-inline: 15px;
position: absolute;
bottom: 40px;
z-index: 10;
width: 100vw;
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
&__title {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
</style>

View File

@ -1,11 +0,0 @@
<script setup lang="ts">
</script>
<template>
boroda
</template>
<style scoped>
</style>

5
plugins/maska.ts Normal file
View File

@ -0,0 +1,5 @@
import { vMaska } from 'maska/vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('maska', vMaska)
})

View File

@ -2,7 +2,6 @@ import type {
DehydratedState, DehydratedState,
VueQueryPluginOptions, VueQueryPluginOptions,
} from '@tanstack/vue-query' } from '@tanstack/vue-query'
// Nuxt 3 app aliases
import { defineNuxtPlugin, useState } from '#imports' import { defineNuxtPlugin, useState } from '#imports'
import { import {
dehydrate, dehydrate,
@ -11,10 +10,12 @@ import {
VueQueryPlugin, VueQueryPlugin,
} from '@tanstack/vue-query' } from '@tanstack/vue-query'
// импортируем Devtools
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
export default defineNuxtPlugin((nuxt) => { export default defineNuxtPlugin((nuxt) => {
const vueQueryState = useState<DehydratedState | null>('vue-query') const vueQueryState = useState<DehydratedState | null>('vue-query')
// Modify your Vue Query global settings here
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@ -40,6 +41,11 @@ export default defineNuxtPlugin((nuxt) => {
if (import.meta.client) { if (import.meta.client) {
nuxt.hooks.hook('app:created', () => { nuxt.hooks.hook('app:created', () => {
hydrate(queryClient, vueQueryState.value) hydrate(queryClient, vueQueryState.value)
// Монтируем Devtools только на клиенте
nuxt.vueApp.use(VueQueryDevtools, {
initialIsOpen: false, // открыть/закрыть по умолчанию
})
}) })
} }
}) })

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

@ -0,0 +1,33 @@
import type { WooOrderCreateRequest, WooOrderCreateResponse } from '#shared/woo_orders_create'
import axios from 'axios'
import { defineEventHandler, readBody } from 'h3'
export default defineEventHandler(async (event): Promise<WooOrderCreateResponse | { error: string }> => {
try {
const orderData = await readBody<WooOrderCreateRequest>(event)
const requestUrl = 'https://wp.koptilnya.xyz/wp-json/wc/v3/orders'
const consumerKey = 'ck_8b5477a1573ce6038ef1367f25d95cede1de4559'
const consumerSecret = 'cs_d0ccaa93e8efe4f76eef0b70c9828a58fc53459f'
const encodedAuth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64')
const response = await axios.post<WooOrderCreateResponse>(requestUrl, orderData, {
headers: {
'Authorization': `Basic ${encodedAuth}`,
'Content-Type': 'application/json',
},
})
return response.data
}
catch (error: any) {
console.error('Ошибка при создании заказа WooCommerce:', error)
return {
error: true,
message: error.response?.data?.message || 'Ошибка при создании заказа',
details: error.response?.data,
}
}
})

View File

@ -0,0 +1,31 @@
import type { YandexLocationDetectRequest, YandexLocationDetectResponse } from '#shared/yandex_location_detect'
import axios from 'axios'
import { defineEventHandler, readBody } from 'h3'
export default defineEventHandler(async (event): Promise<YandexLocationDetectResponse | { error: string }> => {
try {
const data = await readBody<YandexLocationDetectRequest>(event)
const apiUrl = process.env.VITE_YANDEX_B2B_BASE_URL!
const token = process.env.VITE_YANDEX_B2B_TOKEN!
const response = await axios.post<YandexLocationDetectResponse>(
`${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}` }
}
})

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

@ -0,0 +1,32 @@
import type { YandexPvzRequest, YandexPvzResponse } from '#shared/yandex_pvz'
import axios from 'axios'
import { defineEventHandler, readBody } from 'h3'
export default defineEventHandler(async (event): Promise<YandexPvzResponse | { error: string }> => {
try {
const data = await readBody<YandexPvzRequest>(event)
const apiUrl = process.env.VITE_YANDEX_B2B_BASE_URL!
const token = process.env.VITE_YANDEX_B2B_TOKEN!
const response = await axios.post<YandexPvzResponse>(
`${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

@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDRTCCAi0CATMwDQYJKoZIhvcNAQELBQAwXjELMAkGA1UEBhMCUlUxGTAXBgNV
BAcMEFNhaW50LVBldGVyc2J1cmcxDTALBgNVBAoMBEJTUEIxDDAKBgNVBAsMA0RQ
UzEXMBUGA1UEAwwOTUVSQ0hBTlRTX0JTUEIwHhcNMjUwMzA3MDY0MzM3WhcNMjYw
MzA3MDY0MzM3WjBzMQswCQYDVQQGEwJSVTEMMAoGA1UECBMDU1BiMQwwCgYDVQQH
EwNTUGIxDTALBgNVBAoTBEJTUEIxDTALBgNVBAsTBEJTUEIxDzANBgNVBAMTBnBn
dGVzdDEZMBcGCSqGSIb3DQEJARYKcGdAYnNwYi5ydTCCASIwDQYJKoZIhvcNAQEB
BQADggEPADCCAQoCggEBAMSPemQhfttoPicfiel1cW0Hx2HBb0xy/Gn3d1luIVj3
wOdh0eF5mVhbFbgo66eS3o7Arj4PkdV/+Od1KkCCJd96fJj1anPRTSeu2Z+X/l+0
8FqAECzVuYfHN/VDwvkV7d5R3+0+gkGGpodBnsudreTWG9bBVFb4lyCJvZeg+uWl
NyT79gWWkq51xwe+qAtNqKV3wPUUt1A4PXAJ5oJZF6IcXQs2wNRsJ25kiHPO9Wdz
Zibx6vJwnV5HP89hpM0ZdGEJKQT2c60ZfCNw8wDswmZSOp/mQmzkm1uiHOrp2Hdz
A9bqZaXYwhwTcmZBtOcCw+2eiuFiwVfV9nH/R84A2w0CAwEAATANBgkqhkiG9w0B
AQsFAAOCAQEAV7UMCKTCAH+gZsT53kzCXPV3iWgNK2LTle/GVAKZX1Jto4B1Yn4b
0KXmy0/PB2lRuwKogF40MwYN1lvn9qQ7Vohyi5qyd8kK4Z2CI5dYHjfwWjdW2fEz
A0qaV4VCCBZch76zbqRczI8ulUsH+o8cXsNgjw6QmrKJj74Otl6x2hQ34TP9W7yV
J/mOoxDavzulMRj01fcg+DhBqebMypU1okGWeqPMBtYWnpqObYWniKEdqNZ//Cfr
HTVQarDq92T67oCZ7CRu0+Ty7UL/l2SZu4EPlJhlNpg+ZYVqPoGqprjuYJ670l5c
uDxHwAWppDJWeQlwrZRcjuznoj1gtw6ojQ==
-----END CERTIFICATE-----

View File

@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAxI96ZCF+22g+Jx+J6XVxbQfHYcFvTHL8afd3WW4hWPfA52HR
4XmZWFsVuCjrp5LejsCuPg+R1X/453UqQIIl33p8mPVqc9FNJ67Zn5f+X7TwWoAQ
LNW5h8c39UPC+RXt3lHf7T6CQYamh0Gey52t5NYb1sFUVviXIIm9l6D65aU3JPv2
BZaSrnXHB76oC02opXfA9RS3UDg9cAnmglkXohxdCzbA1GwnbmSIc871Z3NmJvHq
8nCdXkc/z2GkzRl0YQkpBPZzrRl8I3DzAOzCZlI6n+ZCbOSbW6Ic6unYd3MD1upl
pdjCHBNyZkG05wLD7Z6K4WLBV9X2cf9HzgDbDQIDAQABAoIBAFpAtyX661C7UK6O
ILj9oBM8GySbuQsVUSkm47pAgzdiq0SS+dfaCbs0N4jT4UCUg3RwrJD6fS/XDubY
OYpdOB6hE0z4guSjGhY6htps4/P2FNa5LrQnfoUFyH/pmUDd/Na0KWm55f1IYnaA
fvVndU05spatNpiolFvwYwmYdRu0LtBkDc80aBVsX2WHuC2TD99qjIFjhNjgVI/v
E8jN3aqXsSFd0tKvBQivsAn/iwx8SyFJmrn2jddy2jewZdF6inB70TjvjY8fWNGb
TV2CIJj0ALmkIMS2/30LNWJZOOpCkQvcnO1seU5WACUDOQhG/iW2lXttRjL6C2PM
P0swDoECgYEA5I+CxZqYE2qa4/mdRZMvX+tlqrVGn3ix2mmVPJaageE6jW/vO2/j
synSpzTml0bpDapsY8Wj6Q30duuoZmQiSDi/uW2+aao81qxr7GWczwPRFdxmZX2i
jJrIe8f2gBkGaCxL2kzXvKyc4hackfI3SHDlXi38G7A0j0u+tU5JvpkCgYEA3Ch8
+04VrUprMmUDj4vt/BKPNk4Y1fVbvHwy9Mkfen4JoJHLUE2dqBizSkdia7RVd8eV
UT4kvS2kVofSt/rG0WkPD8rvtNBa7aqSj6JtvgBKrtbazU7hptzMiqoF/9Rw74b6
E1gbePlSB46ao2LGu1HOe80Rw9Ycq3bElBGrzJUCgYEAirVhmsTIeDghSiupu5io
jqDQcXpUIuHfpfqfvEZ1/E6Q91cwK7UqzgeatSkQrEw3kbiU0TQX1o9GholcCM/K
UmRGTqWAgqXzCCFZ2fyM3sGlOYwphHxrksM42o4vVexaDAyd+BzcbL+g8kDgwl3q
GQeS28Yykycrrq88TNH3RTkCgYAWoa7fYpKF4t2MK6gnDHplbD7+lR6md/d7M8VF
NpueyvAQaoxc7+2iBw//NcFfUwVqL8EgveOm8tcu8f1uXkAr7MHYnMLxcm22es9g
JpFjc8I5oOqTKmW18oKwSnQdbWhCpzxz2p3QXMja8ATjgNbvEKTKQzVtTUhbM/VX
R03C1QKBgGzat0GeWjYoQ8rCG9EuN2qGyvKTpD6VteygIXIVpL9sr1mXHAnfbnZC
OelMdoA/7nfRVIZ4yDbZclx8I0UOGx+rkqiyBgZ/+5BG24HT4sB9uIy5hE/odobR
aC58VQBAHijbEvvhNSXt96asV0tPVcLtG3V4zFDqF3NtR78e0OPg
-----END RSA PRIVATE KEY-----

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[]
}

16
tailwind.config.ts Normal file
View File

@ -0,0 +1,16 @@
// tailwind.config.ts
import type { Config } from 'tailwindcss'
const config: Config = {
content: [], // Nuxt сам заполняет content автоматически
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'], // если используешь @nuxt/fonts или свою кастомную типографику
},
},
},
plugins: [],
}
export default config

View File

@ -1,4 +1,4 @@
{ {
// https://nuxt.com/docs/guide/concepts/typescript // https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json" "extends": "./.nuxt/tsconfig.json",
} }

6803
yarn.lock

File diff suppressed because it is too large Load Diff