Compare commits

..

10 Commits

Author SHA1 Message Date
Yuu Ottosoka
7fc394ae77 sdf
Some checks failed
release-tag / build-and-deploy (push) Has been cancelled
2026-05-23 15:25:49 +03:00
Yuu Ottosoka
6ca7bd8b3e fix services 2026-03-30 23:44:12 +03:00
killua
30e2091ded refactor(api): remove hardcoded gym rental service from yclients response 2025-11-05 22:26:26 +03:00
Yuu Ottosoka
2f4e0d59aa refactor: simplify yclients API integration and improve UI components
remove unused abonements API configuration and endpoints
add escape key handler for drawer component
implement text truncation for service descriptions
sync swiper navigation between service tabs and content
add static gym rental service to services list
2025-09-13 20:40:20 +03:00
Yuu Ottosoka
5eb7407520 style(services): adjust card width in services component 2025-07-20 14:07:12 +03:00
Yuu Ottosoka
7e5f97a9de fix(security): disable browsing topics api in astro and nginx config
Add Permissions-Policy header to disable browsing-topics API to prevent Yandex.Metrika attestation errors
2025-07-20 13:29:47 +03:00
Yuu Ottosoka
710ad62ce5 feat(analytics): add Yandex.Metrika tracking script to base layout
Add Yandex.Metrika counter script to track user interactions and behavior
2025-07-20 13:20:51 +03:00
Yuu Ottosoka
8e40558d14 small improvements 2025-06-11 00:59:48 +03:00
Yuu Ottosoka
4b1af2ddb2 fix 2025-06-10 14:45:50 +03:00
Yuu Ottosoka
1b31e1c5fa fixes 2025-06-10 14:45:27 +03:00
10 changed files with 125 additions and 73 deletions

View File

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

View File

@@ -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' }),
}, },
}, },
}) })

View File

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

View File

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

View File

@@ -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 }}
/> />
)} )}

View File

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

View File

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

View File

@@ -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>> =>
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>[]> { async getServices(): Promise<z.infer<typeof servicesSchema>[]> {
const [booking, abonements] = await Promise.all([ const booking = await fetcher<YclientsBookinRawResponse>(
fetcher<YclientsBookinRawResponse>(`${YCLIENTS_API_URL}/book_services/1254211`, { `${YCLIENTS_API_URL}/book_services/1254211`,
{
method: 'GET', method: 'GET',
}), }
this.getAbonements(), )
])
const personalServicesCatergory = booking.category.find((category) => category.id === 18760529) const services = booking.category?.map((categoryItem) => ({
const abonementsCategory = booking.category.find((category) => category.id === 19562190) title: categoryItem.title,
const personalServiceCharachteristics = booking.services charachteristics: booking.services
.filter((service) => service.category_id === 18760529) .filter((service) => service.category_id === categoryItem.id)
.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,
},
]
}, },
} }

View File

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

View File

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