feat(analytics+seo): GTM events, UTM persistence and fixes, robots/sitemap, perf
- 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:
parent
43b8da73aa
commit
0d497b63a4
18 changed files with 261 additions and 26 deletions
|
|
@ -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.
|
|
@ -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("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
16
src/app/robots.ts
Normal 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
66
src/app/sitemap.ts
Normal 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
|
||||
}
|
||||
26
src/components/analytics/UtmCapture.tsx
Normal file
26
src/components/analytics/UtmCapture.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.")
|
||||
|
|
|
|||
|
|
@ -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("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.")
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
37
src/lib/gtm.ts
Normal 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 })
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue