Compare commits
10 Commits
6f2d34cccf
...
7fc394ae77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc394ae77 | ||
|
|
6ca7bd8b3e | ||
|
|
30e2091ded | ||
|
|
2f4e0d59aa | ||
|
|
5eb7407520 | ||
|
|
7e5f97a9de | ||
|
|
710ad62ce5 | ||
|
|
8e40558d14 | ||
|
|
4b1af2ddb2 | ||
|
|
1b31e1c5fa |
@@ -28,13 +28,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Create .env file
|
- name: Create .env file
|
||||||
run: |
|
run: |
|
||||||
echo ${{ vars.ENV }}
|
echo "YCLIENTS_ABONEMENTS_API_URL=${{ vars.YCLIENTS_ABONEMENTS_API_URL }}" > .env
|
||||||
echo ${{ vars.ENV }} > .env
|
echo "YCLIENTS_ABONEMENTS_TOKEN=${{ vars.YCLIENTS_ABONEMENTS_TOKEN }}" >>.env
|
||||||
|
echo "YCLIENTS_API_URL=${{ vars.YCLIENTS_API_URL }}" >>.env
|
||||||
- name: Debug - Print .env file
|
echo "YCLIENTS_TOKEN=${{ vars.YCLIENTS_TOKEN }}" >>.env
|
||||||
run: |
|
|
||||||
echo "Contents of .env file:"
|
|
||||||
cat .env
|
|
||||||
|
|
||||||
- name: Log in to Docker registry
|
- name: Log in to Docker registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ export default defineConfig({
|
|||||||
prefixDefaultLocale: false,
|
prefixDefaultLocale: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
headers: {
|
||||||
|
'Permissions-Policy': 'browsing-topics=()',
|
||||||
|
},
|
||||||
|
},
|
||||||
env: {
|
env: {
|
||||||
schema: {
|
schema: {
|
||||||
YCLIENTS_API_URL: envField.string({ context: 'server', access: 'public' }),
|
YCLIENTS_API_URL: envField.string({ context: 'server', access: 'public' }),
|
||||||
YCLIENTS_TOKEN: envField.string({ context: 'server', access: 'secret' }),
|
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' }),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,22 @@ const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, loading = false, child
|
|||||||
document.body.style.overflow = isOpen ? 'hidden' : 'auto'
|
document.body.style.overflow = isOpen ? 'hidden' : 'auto'
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -42,7 +58,7 @@ const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, loading = false, child
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed bottom-0 left-0 right-0 w-full md:bottom-0 md:left-auto md:top-0 h-full md:w-[600px] md:rounded-t-none',
|
'fixed bottom-0 left-0 right-0 h-full w-full md:bottom-0 md:left-auto md:top-0 md:w-[600px] md:rounded-t-none',
|
||||||
'transition-transform duration-300 ease-out md:translate-x-full md:translate-y-0',
|
'transition-transform duration-300 ease-out md:translate-x-full md:translate-y-0',
|
||||||
'flex flex-col gap-4 md:flex-row',
|
'flex flex-col gap-4 md:flex-row',
|
||||||
isOpen ? 'translate-y-0 md:translate-x-0' : 'translate-y-full md:translate-x-full'
|
isOpen ? 'translate-y-0 md:translate-x-0' : 'translate-y-full md:translate-x-full'
|
||||||
@@ -51,7 +67,7 @@ const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, loading = false, child
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="ml-2 self-start rounded-full bg-light p-3 focus:outline-none md:ml-0 mt-2"
|
className="ml-2 mt-2 self-start rounded-full bg-light p-3 focus:outline-none md:ml-0"
|
||||||
aria-label="Close drawer"
|
aria-label="Close drawer"
|
||||||
>
|
>
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -64,7 +80,13 @@ const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, loading = false, child
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="h-full w-full overflow-hidden rounded-t-3xl bg-light shadow-2xl md:rounded-none">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full overflow-hidden rounded-t-3xl bg-light md:rounded-none',
|
||||||
|
'md:transition-shadow md:duration-500 md:ease-out',
|
||||||
|
isOpen ? 'md:shadow-[0_0_80px_rgba(198,15,211,0.4)]' : 'md:shadow-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ const t = useTranslations(lang)
|
|||||||
© {new Date().getFullYear()}
|
© {new Date().getFullYear()}
|
||||||
{t('footer.copyright')}
|
{t('footer.copyright')}
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Сделано <a href="https://telegram.me/killua_senpai" target="_blank" class="underline">Mibu</a>
|
||||||
|
</p>
|
||||||
{
|
{
|
||||||
socialMediaLinks && (
|
socialMediaLinks && (
|
||||||
<div class="flex gap-4 text-white">
|
<div class="flex gap-4 text-white">
|
||||||
|
|||||||
@@ -9,17 +9,26 @@ type Props = {
|
|||||||
link?: z.infer<typeof linkSchema>
|
link?: z.infer<typeof linkSchema>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textEllipsis = (text: string, maxLength: number) => {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return text.substring(0, maxLength) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
export const CharachteristicCard: React.FC<Props> = ({ title, description, price, link }) => {
|
export const CharachteristicCard: React.FC<Props> = ({ title, description, price, link }) => {
|
||||||
return (
|
return (
|
||||||
<div className="card flex h-full w-full flex-col justify-between">
|
<div className="card flex h-full w-full flex-col justify-between">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h4>{title}</h4>
|
<h4>{title}</h4>
|
||||||
{description && <span dangerouslySetInnerHTML={{ __html: description }} />}
|
{description && (
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: textEllipsis(description, 240)}} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{price && (
|
{price && (
|
||||||
<span
|
<span
|
||||||
className="text-nowrap text-4xl font-bold text-brand"
|
className="text-nowrap text-4xl font-bold text-white"
|
||||||
dangerouslySetInnerHTML={{ __html: price }}
|
dangerouslySetInnerHTML={{ __html: price }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,17 +13,19 @@ export type ServicesProps = {
|
|||||||
|
|
||||||
export const Services: React.FC<ServicesProps> = ({ services }) => {
|
export const Services: React.FC<ServicesProps> = ({ services }) => {
|
||||||
const swiperRef = useRef<SwiperRef | null>(null)
|
const swiperRef = useRef<SwiperRef | null>(null)
|
||||||
|
const tagSwiper = useRef<SwiperRef | null>(null)
|
||||||
const [serviceId, setServiceId] = useState(0)
|
const [serviceId, setServiceId] = useState(0)
|
||||||
const selectedService = services[serviceId]
|
const selectedService = services[serviceId]
|
||||||
const isSelected = (index: number) => index === serviceId
|
const isSelected = (index: number) => index === serviceId
|
||||||
const updateServiceHandler = useCallback((index: number) => {
|
const updateServiceHandler = useCallback((index: number) => {
|
||||||
setServiceId(index)
|
setServiceId(index)
|
||||||
swiperRef.current?.swiper.slideTo(0)
|
swiperRef.current?.swiper.slideTo(0)
|
||||||
|
tagSwiper.current?.swiper.slideTo(index)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<SimpleSwiper spaceBetween={8}>
|
<SimpleSwiper spaceBetween={8} ref={tagSwiper}>
|
||||||
{services.map((service, index) => (
|
{services.map((service, index) => (
|
||||||
<SwiperSlide style={{ width: 'auto' }} key={index}>
|
<SwiperSlide style={{ width: 'auto' }} key={index}>
|
||||||
<button
|
<button
|
||||||
@@ -38,7 +40,7 @@ export const Services: React.FC<ServicesProps> = ({ services }) => {
|
|||||||
<SimpleSwiper ref={swiperRef}>
|
<SimpleSwiper ref={swiperRef}>
|
||||||
{selectedService.charachteristics?.map((charachteristic, index) => (
|
{selectedService.charachteristics?.map((charachteristic, index) => (
|
||||||
<SwiperSlide
|
<SwiperSlide
|
||||||
className="aspect-3/4 !h-auto w-full max-w-[262px] md:max-w-[320px]"
|
className="aspect-3/4 !h-auto w-full max-w-[280px] md:max-w-[320px]"
|
||||||
key={index}
|
key={index}
|
||||||
>
|
>
|
||||||
<CharachteristicCard
|
<CharachteristicCard
|
||||||
|
|||||||
@@ -36,6 +36,43 @@ const t = useTranslations(Astro.currentLocale)
|
|||||||
<link rel="canonical" href={Astro.url} />
|
<link rel="canonical" href={Astro.url} />
|
||||||
<title>{`${title} | ${t('seo.og.title')}`}</title>
|
<title>{`${title} | ${t('seo.og.title')}`}</title>
|
||||||
<ClientRouter />
|
<ClientRouter />
|
||||||
|
<!-- Yandex.Metrika counter -->
|
||||||
|
<script type="text/javascript">
|
||||||
|
;(function (m, e, t, r, i, k, a) {
|
||||||
|
m[i] =
|
||||||
|
m[i] ||
|
||||||
|
function () {
|
||||||
|
;(m[i].a = m[i].a || []).push(arguments)
|
||||||
|
}
|
||||||
|
m[i].l = 1 * new Date()
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {
|
||||||
|
if (document.scripts[j].src === r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
;(k = e.createElement(t)),
|
||||||
|
(a = e.getElementsByTagName(t)[0]),
|
||||||
|
(k.async = 1),
|
||||||
|
(k.src = r),
|
||||||
|
a.parentNode.insertBefore(k, a)
|
||||||
|
})(window, document, 'script', 'https://mc.yandex.ru/metrika/tag.js', 'ym')
|
||||||
|
|
||||||
|
ym(103393267, 'init', {
|
||||||
|
clickmap: true,
|
||||||
|
trackLinks: true,
|
||||||
|
accurateTrackBounce: true,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<noscript
|
||||||
|
><div>
|
||||||
|
<img
|
||||||
|
src="https://mc.yandex.ru/watch/103393267"
|
||||||
|
style="position:absolute; left:-9999px;"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div></noscript
|
||||||
|
>
|
||||||
|
<!-- /Yandex.Metrika counter -->
|
||||||
</head>
|
</head>
|
||||||
<body class={bodyClass}>
|
<body class={bodyClass}>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import type { servicesSchema } from '@/content.config'
|
import type { servicesSchema } from '@/content.config'
|
||||||
import type { z } from 'astro/zod'
|
import type { z } from 'astro/zod'
|
||||||
import {
|
import { YCLIENTS_TOKEN, YCLIENTS_API_URL } from 'astro:env/server'
|
||||||
YCLIENTS_TOKEN,
|
|
||||||
YCLIENTS_API_URL,
|
|
||||||
YCLIENTS_ABONEMENTS_API_URL,
|
|
||||||
YCLIENTS_ABONEMENTS_TOKEN,
|
|
||||||
} from 'astro:env/server'
|
|
||||||
|
|
||||||
type HTML = string
|
type HTML = string
|
||||||
|
// const RENT_GYM_SERVICE: z.infer<typeof servicesSchema> = {
|
||||||
|
// title: 'Аренда зала',
|
||||||
|
// charachteristics: [
|
||||||
|
// {
|
||||||
|
// title: 'Разовое посещение',
|
||||||
|
// description: 'Разовое посещение студии для самостоятельной тренировки',
|
||||||
|
// price: `${currencyFormatter.format(1500)} ₽`,
|
||||||
|
// link: {
|
||||||
|
// title: 'Записаться',
|
||||||
|
// href: 'https://n1381542.yclients.com/company/1254211/activity/select?o=act',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// }
|
||||||
|
|
||||||
export type YclientsBookinRawResponse = {
|
export type YclientsBookinRawResponse = {
|
||||||
category: {
|
category: {
|
||||||
@@ -56,58 +65,28 @@ const fetcher = <R>(...[path, options = {}]: Parameters<typeof fetch>): Promise<
|
|||||||
}).then((res) => res.json() as Promise<R>)
|
}).then((res) => res.json() as Promise<R>)
|
||||||
|
|
||||||
export const yclientsClient = {
|
export const yclientsClient = {
|
||||||
getAbonements: (): Promise<z.infer<typeof servicesSchema.shape.charachteristics>> =>
|
async getServices(): Promise<z.infer<typeof servicesSchema>[]> {
|
||||||
fetcher<YclientsAbonementsRawResponse>(
|
const booking = await fetcher<YclientsBookinRawResponse>(
|
||||||
`${YCLIENTS_ABONEMENTS_API_URL}/chain/1259425/online_sale/loyalty`,
|
`${YCLIENTS_API_URL}/book_services/1254211`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
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 services = booking.category?.map((categoryItem) => ({
|
||||||
const [booking, abonements] = await Promise.all([
|
title: categoryItem.title,
|
||||||
fetcher<YclientsBookinRawResponse>(`${YCLIENTS_API_URL}/book_services/1254211`, {
|
charachteristics: booking.services
|
||||||
method: 'GET',
|
.filter((service) => service.category_id === categoryItem.id)
|
||||||
}),
|
|
||||||
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) => ({
|
.map((service) => ({
|
||||||
title: service.title,
|
title: service.title,
|
||||||
price: `${service.price_min} ₽`,
|
|
||||||
description: service.comment,
|
description: service.comment,
|
||||||
link: {
|
link: {
|
||||||
title: 'Записаться',
|
title: 'Записаться',
|
||||||
href: `https://n1381542.yclients.com/company/1254211/personal/select-services?o=s${service.id}`,
|
href: `https://n1381542.yclients.com/company/1254211/personal/select-services?o=s${service.id}`,
|
||||||
},
|
},
|
||||||
}))
|
})),
|
||||||
|
})).filter(item => item.charachteristics.length > 0)
|
||||||
|
|
||||||
return [
|
return services
|
||||||
{
|
|
||||||
title: personalServicesCatergory?.title || '',
|
|
||||||
charachteristics: personalServiceCharachteristics,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: abonementsCategory?.title || '',
|
|
||||||
charachteristics: abonements,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useTranslations } from '@/i18n/utils'
|
import { useTranslations } from '@/i18n/utils'
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||||
import Section from '@/components/Section.astro'
|
import Section from '@/components/Section.astro'
|
||||||
import Header, { type HeaderProps } from '@/components/header/Header.astro'
|
import Header from '@/components/header/Header.astro'
|
||||||
import Footer from '@/components/Footer.astro'
|
import Footer from '@/components/Footer.astro'
|
||||||
|
|
||||||
import type { CollectionEntry } from 'astro:content'
|
import type { CollectionEntry } from 'astro:content'
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
@apply rounded-full border border-brand bg-transparent px-6 py-3 text-center text-white transition-colors duration-300 hover:bg-brand;
|
@apply rounded-full border border-brand bg-transparent px-6 py-3 text-center text-white transition-colors duration-300 hover:bg-brand;
|
||||||
}
|
}
|
||||||
.tag-btn {
|
.tag-btn {
|
||||||
@apply hover:tag-btn-active rounded-full bg-light p-2 text-sm transition-colors duration-300;
|
@apply hover:tag-btn-active rounded-full bg-light px-4 py-2 text-sm transition-colors duration-300;
|
||||||
}
|
}
|
||||||
.tag-btn-active {
|
.tag-btn-active {
|
||||||
@apply bg-brand text-white;
|
@apply bg-brand text-white;
|
||||||
|
|||||||
Reference in New Issue
Block a user