diff --git a/Caddyfile b/Caddyfile index 63039ed..7093bc8 100644 --- a/Caddyfile +++ b/Caddyfile @@ -8,5 +8,14 @@ :443 { tls /certs/cert.pem /certs/key.pem + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + reverse_proxy app:3000 } diff --git a/public/videos/shumiland-reels.mp4 b/public/videos/shumiland-reels.mp4 index 55266a8..414640f 100644 Binary files a/public/videos/shumiland-reels.mp4 and b/public/videos/shumiland-reels.mp4 differ diff --git a/public/videos/shumiland-reels.webm b/public/videos/shumiland-reels.webm index 65ab75b..95092f5 100644 Binary files a/public/videos/shumiland-reels.webm and b/public/videos/shumiland-reels.webm differ diff --git a/src/app/(frontend)/checkout/CheckoutClient.tsx b/src/app/(frontend)/checkout/CheckoutClient.tsx index e360b07..4f26418 100644 --- a/src/app/(frontend)/checkout/CheckoutClient.tsx +++ b/src/app/(frontend)/checkout/CheckoutClient.tsx @@ -3,6 +3,7 @@ import { useState, useTransition } from 'react' import { useSearchParams } from 'next/navigation' import { Suspense } from 'react' +import { trackBeginCheckout, trackFormSubmit } from '@/lib/gtm' interface Props { title?: string | null @@ -25,6 +26,7 @@ function CheckoutForm({ title, instructions, terms }: Props) { startTransition(async () => { try { + trackBeginCheckout(0, count) const res = await fetch('/api/tickets/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -41,6 +43,7 @@ function CheckoutForm({ title, instructions, terms }: Props) { return } + trackFormSubmit('checkout') window.location.href = data.url } catch { setError("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.") diff --git a/src/app/(frontend)/korzyna/page.tsx b/src/app/(frontend)/korzyna/page.tsx index 2195ee0..91c7d40 100644 --- a/src/app/(frontend)/korzyna/page.tsx +++ b/src/app/(frontend)/korzyna/page.tsx @@ -3,22 +3,11 @@ import { useState } from 'react' import Link from 'next/link' import { useCart } from '@/context/CartContext' +import { getUtmLeadFields } from '@/lib/utm' +import { trackBeginCheckout, trackFormSubmit } from '@/lib/gtm' const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' } -function getUtmParams(): Record { - if (typeof window === 'undefined') return {} - const p = new URLSearchParams(window.location.search) - return { - utmSource: p.get('utm_source') ?? undefined, - utmMedium: p.get('utm_medium') ?? undefined, - utmCampaign: p.get('utm_campaign') ?? undefined, - utmContent: p.get('utm_content') ?? undefined, - utmTerm: p.get('utm_term') ?? undefined, - gclid: p.get('gclid') ?? undefined, - } -} - export default function KorzynaPage() { const { items, totalCount, totalPrice, updateCount, removeItem, clearCart, hydrated } = useCart() @@ -50,7 +39,8 @@ export default function KorzynaPage() { setSubmitting(true) try { - const utm = getUtmParams() + trackBeginCheckout(totalPrice, totalCount) + const utm = getUtmLeadFields() const checkoutItems = items.map((i) => ({ tariff: i.tariffId, count: String(i.count) })) const [, checkoutRes] = await Promise.allSettled([ @@ -87,6 +77,7 @@ export default function KorzynaPage() { } const { url } = (await checkoutResult.json()) as { url: string } + trackFormSubmit('ticket-purchase') clearCart() window.location.href = url } catch (err) { diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 6dc87a9..38dfb2d 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -7,6 +7,7 @@ import { Footer } from '@/components/layout/Footer' import { CookieBanner } from '@/components/ui/CookieBanner' import { GoogleAnalytics } from '@/components/analytics/GoogleAnalytics' import { GoogleTagManager } from '@/components/analytics/GoogleTagManager' +import { UtmCapture } from '@/components/analytics/UtmCapture' import { BinotelWidget } from '@/components/analytics/BinotelWidget' import { getSiteSettings } from '@/lib/getSiteSettings' import { CartProvider } from '@/context/CartContext' @@ -55,6 +56,7 @@ export default async function FrontendLayout({ children }: { children: React.Rea {settings.gtmId && } {settings.ga4Id && } {settings.binotelId && } +
{children}
diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..e7f6079 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,16 @@ +import type { MetadataRoute } from 'next' + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL?.startsWith('https') + ? process.env.NEXT_PUBLIC_SITE_URL + : 'https://shumiland.com.ua' + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: '*', + allow: '/', + disallow: ['/admin', '/api/', '/korzyna', '/checkout', '/pidtverdzhennya'], + }, + sitemap: `${SITE_URL}/sitemap.xml`, + } +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..08c6166 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,66 @@ +import type { MetadataRoute } from 'next' +import { getPayload } from 'payload' +import configPromise from '@payload-config' + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL?.startsWith('https') + ? process.env.NEXT_PUBLIC_SITE_URL + : 'https://shumiland.com.ua' + +const STATIC_PATHS = [ + '', + '/lokatsii', + '/lokatsii/dynozavry', + '/lokatsii/dyvolis', + '/dni-narodzhennia', + '/grupovi-vidviduvannia', + '/payments', + '/blog', + '/privacy-policy', + '/terms-of-use', + '/oferta', + '/data-processing', +] + +export default async function sitemap(): Promise { + const entries: MetadataRoute.Sitemap = STATIC_PATHS.map((path) => ({ + url: `${SITE_URL}${path}`, + changeFrequency: path === '' || path === '/blog' ? 'weekly' : 'monthly', + priority: path === '' ? 1 : 0.7, + })) + + try { + const payload = await getPayload({ config: configPromise }) + + const { docs: posts } = await payload.find({ + collection: 'blog-posts', + where: { _status: { equals: 'published' } }, + limit: 500, + depth: 0, + }) + for (const post of posts) { + entries.push({ + url: `${SITE_URL}/blog/${post.slug}`, + lastModified: post.updatedAt, + changeFrequency: 'monthly', + priority: 0.6, + }) + } + + const { docs: locations } = await payload.find({ + collection: 'locations', + where: { showDetailPage: { equals: true } }, + limit: 100, + depth: 0, + }) + for (const loc of locations) { + const url = `${SITE_URL}/lokatsii/${loc.slug}` + if (!entries.some((e) => e.url === url)) { + entries.push({ url, changeFrequency: 'monthly', priority: 0.7 }) + } + } + } catch { + // DB unavailable (e.g. at build time) — static paths still ship + } + + return entries +} diff --git a/src/components/analytics/UtmCapture.tsx b/src/components/analytics/UtmCapture.tsx new file mode 100644 index 0000000..452dd76 --- /dev/null +++ b/src/components/analytics/UtmCapture.tsx @@ -0,0 +1,26 @@ +'use client' + +import { useEffect } from 'react' +import { usePathname, useSearchParams } from 'next/navigation' +import { Suspense } from 'react' +import { captureUtmParams } from '@/lib/utm' + +function UtmCaptureInner() { + const pathname = usePathname() + const searchParams = useSearchParams() + + useEffect(() => { + captureUtmParams() + }, [pathname, searchParams]) + + return null +} + +/** Persists UTM params from the landing URL for the whole tab session. */ +export function UtmCapture() { + return ( + + + + ) +} diff --git a/src/components/blocks/LeadFormBlockComponent.tsx b/src/components/blocks/LeadFormBlockComponent.tsx index 718ec7f..12dd609 100644 --- a/src/components/blocks/LeadFormBlockComponent.tsx +++ b/src/components/blocks/LeadFormBlockComponent.tsx @@ -1,6 +1,8 @@ 'use client' import { useState } from 'react' +import { getUtmLeadFields } from '@/lib/utm' +import { trackFormSubmit } from '@/lib/gtm' interface LeadFormBlockProps { title?: string | null @@ -38,9 +40,11 @@ export function LeadFormBlockComponent({ name, phone: showPhone ? phone : undefined, email: showEmail ? email : undefined, - source: formSource ?? 'block-form', + formSource: formSource ?? 'block-form', + ...getUtmLeadFields(), }), }) + trackFormSubmit(formSource ?? 'block-form') } catch { /* noop */ } diff --git a/src/components/blocks/NewsletterFormBlockComponent.tsx b/src/components/blocks/NewsletterFormBlockComponent.tsx index 7ae2f0a..a8f1759 100644 --- a/src/components/blocks/NewsletterFormBlockComponent.tsx +++ b/src/components/blocks/NewsletterFormBlockComponent.tsx @@ -1,6 +1,7 @@ 'use client' import { useState } from 'react' +import { trackFormSubmit } from '@/lib/gtm' interface NewsletterFormBlockProps { title?: string | null @@ -18,6 +19,7 @@ export function NewsletterFormBlockComponent({ function handleSubmit(e: React.FormEvent) { e.preventDefault() + trackFormSubmit('newsletter') setSubmitted(true) } diff --git a/src/components/forms/BirthdayBookingForm.tsx b/src/components/forms/BirthdayBookingForm.tsx index 35ccfb8..0c912b9 100644 --- a/src/components/forms/BirthdayBookingForm.tsx +++ b/src/components/forms/BirthdayBookingForm.tsx @@ -1,7 +1,8 @@ 'use client' import { useState, useTransition } from 'react' -import { getUtmParams } from '@/lib/utm' +import { getUtmLeadFields } from '@/lib/utm' +import { trackFormSubmit } from '@/lib/gtm' const FONT = 'var(--font-montserrat, Montserrat), sans-serif' @@ -34,7 +35,7 @@ export function BirthdayBookingForm({ defaultPackage }: BirthdayBookingFormProps startTransition(async () => { try { - const utm = getUtmParams() + const utm = getUtmLeadFields() const res = await fetch('/api/leads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -58,6 +59,7 @@ export function BirthdayBookingForm({ defaultPackage }: BirthdayBookingFormProps setError(data.error ?? 'Щось пішло не так. Спробуйте ще раз.') return } + trackFormSubmit('birthday') setSuccess(true) } catch { setError("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.") diff --git a/src/components/forms/GroupRequestForm.tsx b/src/components/forms/GroupRequestForm.tsx index 5781621..18fc4c8 100644 --- a/src/components/forms/GroupRequestForm.tsx +++ b/src/components/forms/GroupRequestForm.tsx @@ -1,7 +1,8 @@ 'use client' import { useState, useTransition } from 'react' -import { getUtmParams } from '@/lib/utm' +import { getUtmLeadFields } from '@/lib/utm' +import { trackFormSubmit } from '@/lib/gtm' const FONT = 'var(--font-montserrat, Montserrat), sans-serif' @@ -30,7 +31,7 @@ export function GroupRequestForm() { startTransition(async () => { try { - const utm = getUtmParams() + const utm = getUtmLeadFields() const res = await fetch('/api/leads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -53,6 +54,7 @@ export function GroupRequestForm() { setError(data.error ?? 'Щось пішло не так. Спробуйте ще раз.') return } + trackFormSubmit('group-visits') setSuccess(true) } catch { setError("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.") diff --git a/src/components/sections/VideoSection.tsx b/src/components/sections/VideoSection.tsx index f95ddf6..73a0b3f 100644 --- a/src/components/sections/VideoSection.tsx +++ b/src/components/sections/VideoSection.tsx @@ -1,7 +1,7 @@ 'use client' /* eslint-disable @next/next/no-img-element */ -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { Media } from '@/types/globals' const DEFAULT_MP4 = '/videos/shumiland-reels.mp4' @@ -29,10 +29,30 @@ interface VideoSectionProps { export function VideoSection({ poster, src }: VideoSectionProps) { const [ytPlaying, setYtPlaying] = useState(false) const [muted, setMuted] = useState(true) + // Defer loading the video file until the section scrolls into view — + // otherwise the autoplay download competes with the LCP hero image. + const [inView, setInView] = useState(false) const videoRef = useRef(null) const posterUrl = getMediaUrl(poster) ?? DEFAULT_POSTER const youtubeEmbed = src ? getYouTubeEmbedUrl(src) : null + useEffect(() => { + const video = videoRef.current + if (!video || inView) return + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) setInView(true) + }, + { rootMargin: '200px' } + ) + observer.observe(video) + return () => observer.disconnect() + }, [inView]) + + useEffect(() => { + if (inView) videoRef.current?.load() + }, [inView]) + function toggleMute() { const video = videoRef.current if (!video) return @@ -100,8 +120,8 @@ export function VideoSection({ poster, src }: VideoSectionProps) { preload="metadata" poster={posterUrl} > - - + {inView && } + {inView && }