feat(analytics+seo): GTM events, UTM persistence and fixes, robots/sitemap, perf
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions

- GTM dataLayer events: add_to_cart (CartContext), begin_checkout,
  form_submit_success on all forms and checkout
- UTM: persist landing params in sessionStorage (UtmCapture in layout);
  fix field names — forms sent utm_source while /api/leads expects
  utmSource, so UTM was silently dropped; LeadFormBlock also sent
  'source' instead of required 'formSource' (leads were rejected)
- robots.ts + sitemap.ts (static pages, blog posts, locations)
- PageHero: alt text for hero background
- VideoSection: lazy-load video on scroll into view; hero reels
  transcoded 57.5MB -> 9.6MB mp4 / 33.7MB -> 10.9MB webm
- Caddy: HSTS, nosniff, X-Frame-Options, Referrer-Policy headers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-06-12 11:32:58 +01:00
parent 43b8da73aa
commit 0d497b63a4
18 changed files with 261 additions and 26 deletions

View file

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

Binary file not shown.

Binary file not shown.

View file

@ -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("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.")

View file

@ -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<string, string | undefined> {
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) {

View file

@ -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 && <GoogleTagManager gtmId={settings.gtmId} />}
{settings.ga4Id && <GoogleAnalytics measurementId={settings.ga4Id} />}
{settings.binotelId && <BinotelWidget widgetId={settings.binotelId} />}
<UtmCapture />
<CartProvider>
<Header />
<main className="flex-1">{children}</main>

16
src/app/robots.ts Normal file
View file

@ -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`,
}
}

66
src/app/sitemap.ts Normal file
View file

@ -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<MetadataRoute.Sitemap> {
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
}

View file

@ -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 (
<Suspense fallback={null}>
<UtmCaptureInner />
</Suspense>
)
}

View file

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

View file

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

View file

@ -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("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.")

View file

@ -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("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.")

View file

@ -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<HTMLVideoElement>(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}
>
<source src={DEFAULT_WEBM} type="video/webm" />
<source src={DEFAULT_MP4} type="video/mp4" />
{inView && <source src={DEFAULT_WEBM} type="video/webm" />}
{inView && <source src={DEFAULT_MP4} type="video/mp4" />}
</video>
<button
onClick={toggleMute}

View file

@ -7,6 +7,7 @@ interface PageHeroProps {
title: string
subtitle?: string
bgSrc?: string | null
bgAlt?: string | null
children?: ReactNode
}
@ -15,12 +16,20 @@ interface PageHeroProps {
* Header height: 60px mobile / 120px desktop negative top margin pulls this section up.
* Accepts an optional bgSrc photo; falls back to DEFAULT_BG with a dark-green overlay.
*/
export function PageHero({ title, subtitle, bgSrc, children }: PageHeroProps) {
export function PageHero({ title, subtitle, bgSrc, bgAlt, children }: PageHeroProps) {
// Strip absolute origin so next/image treats Payload media as a local (optimizable) URL.
const src = (bgSrc ?? DEFAULT_BG).replace(/^https?:\/\/[^/]+/, '')
return (
<div className="relative -mt-[60px] overflow-hidden px-8 pt-[calc(60px+48px)] pb-16 lg:-mt-[120px] lg:pt-[calc(120px+64px)]">
<Image src={src} alt="" fill priority quality={90} sizes="100vw" className="object-cover" />
<Image
src={src}
alt={bgAlt ?? title}
fill
priority
quality={90}
sizes="100vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/30 via-black/10 to-black/40" />
<div className="relative z-10 mx-auto max-w-[1140px] text-white">
<h1

View file

@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useReducer } from 'react'
import type { ReactNode } from 'react'
import type { CartItem } from '@/lib/cart'
import { loadCart, saveCart } from '@/lib/cart'
import { trackAddToCart } from '@/lib/gtm'
// ── State & Actions ──────────────────────────────────────────────
@ -100,7 +101,10 @@ export function CartProvider({ children }: { children: ReactNode }) {
totalCount,
totalPrice,
hydrated: state.hydrated,
addItem: (item) => dispatch({ type: 'ADD_ITEM', item }),
addItem: (item) => {
trackAddToCart(item.name, item.price)
dispatch({ type: 'ADD_ITEM', item })
},
removeItem: (tariffId) => dispatch({ type: 'REMOVE_ITEM', tariffId }),
updateCount: (tariffId, count) => dispatch({ type: 'UPDATE_COUNT', tariffId, count }),
clearCart: () => dispatch({ type: 'CLEAR_CART' }),

37
src/lib/gtm.ts Normal file
View file

@ -0,0 +1,37 @@
// GTM dataLayer events: buy-button clicks and successful form submissions.
// Events fire even before gtm.js loads — GTM drains the queue on init.
type DataLayerEvent = { event: string } & Record<string, unknown>
declare global {
interface Window {
dataLayer?: DataLayerEvent[]
}
}
export function gtmPush(event: DataLayerEvent): void {
if (typeof window === 'undefined') return
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push(event)
}
/** Click on any «Купити квиток» / «До кошика» button. */
export function trackAddToCart(itemName: string, price: number | null, quantity = 1): void {
gtmPush({
event: 'add_to_cart',
currency: 'UAH',
item_name: itemName,
price: price ?? undefined,
quantity,
})
}
/** Click on «Оплатити» / «Перейти до оплати». */
export function trackBeginCheckout(value: number, itemCount: number): void {
gtmPush({ event: 'begin_checkout', currency: 'UAH', value, item_count: itemCount })
}
/** Successful form submission (lead / newsletter / checkout). */
export function trackFormSubmit(formSource: string): void {
gtmPush({ event: 'form_submit_success', form_source: formSource })
}

View file

@ -9,9 +9,51 @@ const UTM_WHITELIST = [
export type UtmParams = Partial<Record<(typeof UTM_WHITELIST)[number], string>>
const STORAGE_KEY = 'shumi_utm'
/**
* Persist UTM params from the landing URL so leads keep their source
* after the visitor navigates to another page. Call once on page load.
*/
export function captureUtmParams(): void {
if (typeof window === 'undefined') return
const fromUrl = parseQuery(new URLSearchParams(window.location.search))
if (Object.keys(fromUrl).length === 0) return
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fromUrl))
} catch {
// sessionStorage unavailable (private mode) — URL params still work
}
}
export function getUtmParams(): UtmParams {
if (typeof window === 'undefined') return {}
return parseQuery(new URLSearchParams(window.location.search))
const fromUrl = parseQuery(new URLSearchParams(window.location.search))
if (Object.keys(fromUrl).length > 0) return fromUrl
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}') as UtmParams
} catch {
return {}
}
}
// /api/leads (Zod schema) expects camelCase field names.
const LEAD_FIELD_MAP: Record<keyof UtmParams & string, string> = {
utm_source: 'utmSource',
utm_medium: 'utmMedium',
utm_campaign: 'utmCampaign',
utm_content: 'utmContent',
utm_term: 'utmTerm',
gclid: 'gclid',
}
/** UTM params keyed for the /api/leads payload (camelCase). */
export function getUtmLeadFields(): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(getUtmParams())) {
if (value) result[LEAD_FIELD_MAP[key] ?? key] = value
}
return result
}
export function parseQuery(searchParams: URLSearchParams): UtmParams {