получаю сервисы из yclients

This commit is contained in:
Yuu Ottosoka
2025-06-10 14:32:39 +03:00
parent 8359ab14ea
commit fadb188200
10 changed files with 157 additions and 124 deletions

View File

@@ -1,5 +1,5 @@
// @ts-check
import { defineConfig } from 'astro/config'
import { defineConfig, envField } from 'astro/config'
import tailwind from '@astrojs/tailwind'
import react from '@astrojs/react'
@@ -15,4 +15,12 @@ export default defineConfig({
prefixDefaultLocale: false,
},
},
env: {
schema: {
YCLIENTS_API_URL: envField.string({ context: 'server', access: 'public' }),
YCLIENTS_TOKEN: envField.string({ context: 'server', access: 'secret' }),
YCLIENTS_ABONEMENTS_API_URL: envField.string({ context: 'server', access: 'public' }),
YCLIENTS_ABONEMENTS_TOKEN: envField.string({ context: 'server', access: 'secret' }),
},
},
})

Binary file not shown.

View File

@@ -26,13 +26,6 @@ const t = useTranslations(lang)
<p class="text-light text-sm">
© {new Date().getFullYear()}
{t('footer.copyright')}
<a
href="/docs/oferta.pdf"
target="_blank"
class="text-light text-sm underline underline-offset-4 transition-colors duration-300 hover:text-white"
>
{t('oferta')}
</a>
</p>
{
socialMediaLinks && (

View File

@@ -14,7 +14,7 @@ export const CharachteristicCard: React.FC<Props> = ({ title, description, price
<div className="card flex h-full w-full flex-col justify-between">
<div className="flex flex-col gap-2">
<h4>{title}</h4>
{description && <span>{description}</span>}
{description && <span dangerouslySetInnerHTML={{ __html: description }} />}
</div>
<div className="flex flex-col gap-4">
{price && (
@@ -23,7 +23,9 @@ export const CharachteristicCard: React.FC<Props> = ({ title, description, price
dangerouslySetInnerHTML={{ __html: price }}
/>
)}
<YClientsWidgetButton className="brand-btn-outline">{link?.title}</YClientsWidgetButton>
<YClientsWidgetButton className="brand-btn-outline" widgetLink={link?.href}>
{link?.title}
</YClientsWidgetButton>
</div>
</div>
)

View File

@@ -1,19 +1,20 @@
import React, { useState, useRef } from 'react'
import Drawer from '../Drawer'
import { isYClientsWidgetOpen, toggleYClientsWidget } from '@/stores/yclientsWidgetStore'
import { YClientsWidgetState, toggleYClientsWidget } from '@/stores/yclientsWidgetStore'
import { useStore } from '@nanostores/react'
const YclientsWidget: React.FC = () => {
const [loading, setLoading] = useState(false)
const iframe = useRef<HTMLIFrameElement>(null)
const $isYClientsWidgetOpen = useStore(isYClientsWidgetOpen)
const $widgetState = useStore(YClientsWidgetState)
return (
<Drawer onClose={toggleYClientsWidget} isOpen={$isYClientsWidgetOpen} loading={loading}>
<Drawer onClose={toggleYClientsWidget} isOpen={$widgetState.isOpen} loading={loading}>
<iframe
ref={iframe}
className="h-full w-full"
src="https://w1381542.yclients.com/"
// @ts-expect-error null тоже можно передавать в src чтобы обнулить iframe
src={$widgetState.link}
onLoad={() => setLoading(false)}
/>
</Drawer>

View File

@@ -1,11 +1,16 @@
import { toggleYClientsWidget } from '@/stores/yclientsWidgetStore'
import type { ButtonHTMLAttributes } from 'react'
export const YClientsWidgetButton: React.FC<ButtonHTMLAttributes<unknown>> = ({
type YClientsWidgetButtonProps = ButtonHTMLAttributes<unknown> & {
widgetLink?: string
}
export const YClientsWidgetButton: React.FC<YClientsWidgetButtonProps> = ({
children,
widgetLink,
...props
}) => (
<button {...props} onClick={toggleYClientsWidget}>
<button {...props} onClick={() => toggleYClientsWidget({ link: widgetLink })}>
{children}
</button>
)

View File

@@ -62,110 +62,6 @@ slider:
imageAlt: 'twenty-fifth-slide'
- image: './assets/slider/IMG_7075.jpg'
imageAlt: 'twenty-sixth-slide'
services:
- title: 'Тренировка с тренером'
link:
title: 'Записаться'
href: 'https://wa.me/message/JKEYZFZBLQBIF1'
target: '_blank'
charachteristics:
- title: 'Разовая тренировка'
description: 'Персональная тренировка 60 минут. Включает консультацию, обучение технике упражнений и рекомендации по питанию.'
price: '5 000 ₽'
link:
title: 'Записаться'
href: 'https://wa.me/p/9063789057065586/79804450446'
target: '_blank'
- title: 'Разовая СПЛИТ'
description: 'Тренировка 75 минут для двух человек. Обучение технике, консультации по питанию и тренировкам.'
price: '8 000 ₽'
link:
title: 'Записаться'
href: 'https://wa.me/p/9525979067500181/79804450446'
target: '_blank'
- title: 'Блок 5 тренировок'
description: 'Пять тренировок по 60 минут с планом питания и БЖУ. Срок - 40 дней.'
price: '22 500 ₽'
link:
title: 'Записаться'
href: 'https://wa.me/p/24101248972846560/79804450446'
target: '_blank'
- title: 'Блок 5 СПЛИТ'
description: 'Пять парных тренировок по 75 минут. План тренировок и питания. Срок - 40 дней.'
price: '38 500 ₽'
link:
title: 'Записаться'
href: 'https://wa.me/79804450446?text=Здравствуйте!%20Хочу%20приобрести%20блок%205%20СПЛИТ%20тренировок'
target: '_blank'
- title: 'Блок 10 тренировок'
description: 'Десять часовых тренировок. Программа тренировок и питания с регулярной корректировкой.'
price: '42 500 ₽'
link:
title: 'Записаться'
href: 'https://wa.me/79804450446?text=Здравствуйте!%20Хочу%20приобрести%20блок%2010%20персональных%20тренировок'
target: '_blank'
- title: 'Блок 10 СПЛИТ'
description: 'Десять парных тренировок по 75 минут. Полное сопровождение с корректировкой программы.'
price: '75 000 ₽'
link:
title: 'Записаться'
href: 'https://wa.me/79804450446?text=Здравствуйте!%20Хочу%20приобрести%20блок%2010%20СПЛИТ%20тренировок'
target: '_blank'
- title: 'Блок 15 тренировок'
description: 'Пятнадцать часовых тренировок. Расширенная программа тренировок и питания.'
price: '60 000 ₽'
link:
title: 'Записаться'
href: 'https://wa.me/79804450446?text=Здравствуйте!%20Хочу%20приобрести%20блок%2015%20персональных%20тренировок'
target: '_blank'
- title: 'Блок 15 СПЛИТ'
description: 'Пятнадцать парных тренировок. Максимально эффективная программа с мониторингом.'
price: '110 000 ₽'
link:
title: 'Записаться'
href: 'https://wa.me/79804450446?text=Здравствуйте!%20Хочу%20приобрести%20блок%2015%20СПЛИТ%20тренировок'
target: '_blank'
- title: 'Безлимитные Абонементы'
description: '30 календарных дней занятий с тренером'
charachteristics:
- title: 'Персональный безлимит'
description: 'Неограниченное количество персональных тренировок на 30 дней.'
price: '50 000 ₽'
link:
title: 'Записаться'
href: 'https://wa.me/p/9194792393933300/79804450446'
target: '_blank'
- title: 'СПЛИТ безлимит'
description: 'Безлимитные парные тренировки на месяц.'
price: '80 000 ₽'
link:
title: 'Записаться'
href: 'https://wa.me/p/9211908478906057/79804450446'
target: '_blank'
- title: 'Мини-группа безлимит'
description: 'Безлимитные тренировки для 3-5 человек на месяц.'
price: '30 000 ₽<br />c человека'
link:
title: 'Записаться'
href: 'https://wa.me/p/9767701363241058/79804450446'
target: '_blank'
- title: 'Аренда зала / Разовые посещения'
description: 'Присутствие тренера БЕСПЛАТНО'
charachteristics:
- title: 'Разовое посещение'
description: 'Свободное посещение зала 75 минут с доступом к оборудованию.'
price: '1500 ₽<br />c человека'
link:
title: 'Записаться'
href: 'https://wa.me/p/9987000717998326/79804450446'
target: '_blank'
- title: 'Приходящий тренер'
description: 'Тренировка 75 минут со своим тренером.'
price: '1500 ₽<br />c человека'
link:
title: 'Записаться'
href: 'https://wa.me/p/8974731312631431/79804450446'
target: '_blank'
reviews:
- review: 'Место силы. Комфортная студия. Для тренировок есть всё необходимое . Раздевалки уютные , так же всё продумано до мелочей . Можно после тренировки насладиться кофе . По месторасположению очень комфортно , для парковки места есть всегда. Уютно. Спортивно . Красиво . Стильно . Профессионально! От души рекомендую'
author:

113
src/libs/api/yclients.ts Normal file
View File

@@ -0,0 +1,113 @@
import type { servicesSchema } from '@/content.config'
import type { z } from 'astro/zod'
import {
YCLIENTS_TOKEN,
YCLIENTS_API_URL,
YCLIENTS_ABONEMENTS_API_URL,
YCLIENTS_ABONEMENTS_TOKEN,
} from 'astro:env/server'
type HTML = string
export type YclientsBookinRawResponse = {
category: {
id: number
title: string
}[]
services: {
id: number
title: string
price_min: number
price_max: number
discount: number
comment: string
weight: number
active: number
sex: number
image: string
category_id: number
}[]
}
export type YClientsAbonementRawItem = {
id: number
image: string
online_sale_title: string
online_sale_description: HTML
online_sale_price: number
online_sale_image: string
cost: number
price: number
}
export type YclientsAbonementsRawResponse = {
data: {
loyalty_abonement_types: YClientsAbonementRawItem[]
}
}
const fetcher = <R>(...[path, options = {}]: Parameters<typeof fetch>): Promise<R> =>
fetch(path, {
...options,
headers: {
Authorization: `Bearer ${YCLIENTS_TOKEN}`,
...(options.headers || {}),
},
}).then((res) => res.json() as Promise<R>)
export const yclientsClient = {
getAbonements: (): Promise<z.infer<typeof servicesSchema.shape.charachteristics>> =>
fetcher<YclientsAbonementsRawResponse>(
`${YCLIENTS_ABONEMENTS_API_URL}/chain/1259425/online_sale/loyalty`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${YCLIENTS_ABONEMENTS_TOKEN}`,
},
}
).then((res) =>
res.data.loyalty_abonement_types.map((rawItem) => ({
title: rawItem.online_sale_title,
description: rawItem.online_sale_description,
price: `${rawItem.online_sale_price} ₽`,
link: {
title: 'Купить абонемент',
href: `https://o9998.yclients.com/subscriptions/${rawItem.id}`,
},
}))
),
async getServices(): Promise<z.infer<typeof servicesSchema>[]> {
const [booking, abonements] = await Promise.all([
fetcher<YclientsBookinRawResponse>(`${YCLIENTS_API_URL}/book_services/1254211`, {
method: 'GET',
}),
this.getAbonements(),
])
const personalServicesCatergory = booking.category.find((category) => category.id === 18760529)
const abonementsCategory = booking.category.find((category) => category.id === 19562190)
const personalServiceCharachteristics = booking.services
.filter((service) => service.category_id === 18760529)
.map((service) => ({
title: service.title,
price: `${service.price_min} ₽`,
description: service.comment,
link: {
title: 'Записаться',
href: `https://n1381542.yclients.com/company/1254211/personal/select-services?o=s${service.id}`,
},
}))
return [
{
title: personalServicesCatergory?.title || '',
charachteristics: personalServiceCharachteristics,
},
{
title: abonementsCategory?.title || '',
charachteristics: abonements,
},
]
},
}

View File

@@ -17,6 +17,7 @@ import { Services } from '@/components/services/Services'
import { GymGallery } from '@/components/GymGallery'
import { Coaches } from '@/components/coaches/Coaches'
import YclientsWidget from '@/components/yclients-widget/YclientsWidget'
import { yclientsClient } from '@/libs/api/yclients'
type Props = CollectionEntry<'pages'>['data']
@@ -27,11 +28,13 @@ const [page, coaches] = await Promise.all([
coaches.sort((a, b) => (a.data.order ?? -1) - (b.data.order ?? -1))
),
])
const { slider, services, hero, marquee, reviews, footer } = page?.data || {}
const { slider, hero, marquee, reviews, footer } = page?.data || {}
const optimizedSlider = slider
? await optimizeSliderImages(slider.map(({ image, imageAlt }) => ({ src: image, alt: imageAlt })))
: undefined
const services = await yclientsClient.getServices()
const optimizedReviews = reviews
? await Promise.all(
reviews.map(async (review): Promise<OptimizedReview> => {

View File

@@ -1,6 +1,18 @@
import { atom } from 'nanostores'
export const isYClientsWidgetOpen = atom(false)
export const toggleYClientsWidget = () => {
isYClientsWidgetOpen.set(!isYClientsWidgetOpen.get())
type ToggleWidgetProps = {
link?: string | null
}
export const YClientsWidgetState = atom<{ isOpen: boolean; link: string | null }>({
isOpen: false,
link: null,
})
export const toggleYClientsWidget = ({ link = null }: ToggleWidgetProps = {}) => {
const { isOpen } = YClientsWidgetState.get()
YClientsWidgetState.set({
isOpen: !isOpen,
link,
})
}