feat(cart): add persistent cart flow with /korzyna checkout page
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

- CartContext + localStorage persistence across page navigation
- CartIcon with badge count in header (desktop + mobile)
- TariffCardClient: −/+ counter + "До кошика" on /kvytky
- DyvoLisTickets: fetch live tariffs, each card adds to cart
- PricingBlockComponent: split server/client, addToCart button
- /korzyna page: items, total, name/phone/email form, terms checkbox,
  "Оплатити N ₴" button — parallel POST to /api/leads + /api/tickets/checkout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-13 15:22:05 +01:00
parent 5035370cb2
commit e4b259afdc
12 changed files with 841 additions and 162 deletions

View file

@ -0,0 +1,323 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useCart } from '@/context/CartContext'
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()
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [email, setEmail] = useState('')
const [terms, setTerms] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [globalError, setGlobalError] = useState('')
function validate() {
const e: Record<string, string> = {}
if (name.trim().length < 2) e.name = "Введіть ваше ім'я"
if (!/^[+\d\s\-().]{9,20}$/.test(phone.trim())) e.phone = 'Введіть коректний номер телефону'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) e.email = 'Введіть коректний email'
return e
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setGlobalError('')
const errs = validate()
if (Object.keys(errs).length) {
setErrors(errs)
return
}
setErrors({})
setSubmitting(true)
try {
const utm = getUtmParams()
const checkoutItems = items.map((i) => ({ tariff: i.tariffId, count: String(i.count) }))
const [, checkoutRes] = await Promise.allSettled([
fetch('/api/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
phone: phone.trim(),
email: email.trim(),
formSource: 'ticket-purchase',
...utm,
}),
}),
fetch('/api/tickets/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), items: checkoutItems }),
}),
])
if (checkoutRes.status === 'rejected') {
throw new Error('Network error')
}
const checkoutResult = checkoutRes.value
if (!checkoutResult.ok) {
const body = await checkoutResult.json().catch(() => ({}))
throw new Error((body as { error?: string }).error ?? 'Помилка оплати')
}
const { url } = (await checkoutResult.json()) as { url: string }
clearCart()
window.location.href = url
} catch (err) {
setSubmitting(false)
setGlobalError(err instanceof Error ? err.message : 'Щось пішло не так. Спробуйте ще раз.')
}
}
// Empty state
if (hydrated && items.length === 0) {
return (
<div className="min-h-screen bg-[#f1fbeb]">
<div className="mx-auto flex max-w-[600px] flex-col items-center px-6 py-24 text-center">
<div className="mb-6 text-[80px]">🛒</div>
<h1
className="mb-4 text-[28px] font-bold text-[#272727]"
style={FONT_MONT}
>
Кошик порожній
</h1>
<p className="mb-8 text-[16px] text-[#4a4a4a]" style={FONT_MONT}>
Оберіть квитки та додайте їх у кошик
</p>
<Link
href="/kvytky"
className="rounded-[64px] px-10 py-3 text-[16px] font-bold text-[#1a1a1a] transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{
...FONT_MONT,
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
>
Обрати квитки
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-[#f1fbeb]">
<div className="mx-auto max-w-[780px] px-4 py-10 md:px-6 md:py-16">
<h1
className="mb-8 text-[28px] font-bold text-[#272727] md:text-[36px]"
style={FONT_MONT}
>
Ваш кошик
</h1>
{/* Items */}
<div className="mb-6 overflow-hidden rounded-[20px] bg-white shadow-[0_4px_40px_rgba(0,0,0,0.06)]">
{items.map((item) => (
<div
key={item.tariffId}
className="flex items-center gap-3 border-b border-[#f0ece6] px-5 py-4 last:border-none"
>
{item.icon && (
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] bg-[#396817] text-[22px]">
{item.icon}
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-[14px] font-bold text-[#272727]" style={FONT_MONT}>
{item.name}
</p>
<p className="text-[13px] text-[#888]" style={FONT_MONT}>
{item.price} / шт
</p>
</div>
{/* Counter */}
<div className="flex items-center gap-2">
<button
onClick={() => updateCount(item.tariffId, item.count - 1)}
aria-label="Зменшити"
className="flex h-8 w-8 items-center justify-center rounded-full text-[18px] font-bold text-white transition-opacity hover:opacity-80"
style={{ background: '#f28b4a' }}
>
</button>
<span
className="min-w-[24px] text-center text-[15px] font-bold text-[#272727]"
style={FONT_MONT}
>
{item.count}
</span>
<button
onClick={() => updateCount(item.tariffId, item.count + 1)}
aria-label="Збільшити"
className="flex h-8 w-8 items-center justify-center rounded-full text-[18px] font-bold text-white transition-opacity hover:opacity-80"
style={{ background: '#f28b4a' }}
>
+
</button>
</div>
<span
className="w-[70px] text-right text-[15px] font-bold text-[#2d5212]"
style={FONT_MONT}
>
{item.price * item.count}
</span>
<button
onClick={() => removeItem(item.tariffId)}
aria-label="Видалити"
className="ml-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#bbb] transition-colors hover:bg-red-50 hover:text-red-400"
>
</button>
</div>
))}
</div>
{/* Total */}
<div
className="mb-8 flex items-center justify-between rounded-[16px] px-6 py-4"
style={{ background: '#2d5212' }}
>
<span className="text-[15px] text-white" style={FONT_MONT}>
Разом: {totalCount} {totalCount === 1 ? 'квиток' : totalCount < 5 ? 'квитки' : 'квитків'}
</span>
<span className="text-[24px] font-black text-[#fdcf54]" style={FONT_MONT}>
{totalPrice}
</span>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
noValidate
className="overflow-hidden rounded-[20px] bg-white p-6 shadow-[0_4px_40px_rgba(0,0,0,0.06)] md:p-8"
>
<h2 className="mb-6 text-[20px] font-bold text-[#272727]" style={FONT_MONT}>
Контактні дані
</h2>
<div className="mb-4">
<label className="mb-1 block text-[12px] font-bold uppercase tracking-wide text-[#888]" style={FONT_MONT}>
Ім&apos;я *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Іван Петренко"
className={`w-full rounded-[10px] border px-4 py-3 text-[14px] text-[#272727] outline-none transition-colors focus:border-[#f28b4a] ${errors.name ? 'border-red-400 bg-red-50' : 'border-[#e0d8ce] bg-[#fafaf8]'}`}
style={FONT_MONT}
/>
{errors.name && <p className="mt-1 text-[12px] text-red-500">{errors.name}</p>}
</div>
<div className="mb-4">
<label className="mb-1 block text-[12px] font-bold uppercase tracking-wide text-[#888]" style={FONT_MONT}>
Телефон *
</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+380 67 123 45 67"
className={`w-full rounded-[10px] border px-4 py-3 text-[14px] text-[#272727] outline-none transition-colors focus:border-[#f28b4a] ${errors.phone ? 'border-red-400 bg-red-50' : 'border-[#e0d8ce] bg-[#fafaf8]'}`}
style={FONT_MONT}
/>
{errors.phone && <p className="mt-1 text-[12px] text-red-500">{errors.phone}</p>}
</div>
<div className="mb-6">
<label className="mb-1 block text-[12px] font-bold uppercase tracking-wide text-[#888]" style={FONT_MONT}>
Email * <span className="normal-case font-normal text-[#aaa]">(квитки надійдуть сюди)</span>
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ivan@example.com"
className={`w-full rounded-[10px] border px-4 py-3 text-[14px] text-[#272727] outline-none transition-colors focus:border-[#f28b4a] ${errors.email ? 'border-red-400 bg-red-50' : 'border-[#e0d8ce] bg-[#fafaf8]'}`}
style={FONT_MONT}
/>
{errors.email && <p className="mt-1 text-[12px] text-red-500">{errors.email}</p>}
</div>
{/* Terms */}
<label className="mb-6 flex cursor-pointer items-start gap-3 rounded-[12px] bg-[#f9f5f0] p-4">
<input
type="checkbox"
checked={terms}
onChange={(e) => setTerms(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 accent-[#f28b4a]"
/>
<span className="text-[13px] leading-relaxed text-[#555]" style={FONT_MONT}>
Я погоджуюсь з{' '}
<Link href="/umovy-vidviduvannia" className="text-[#2d5212] underline underline-offset-2">
умовами відвідування
</Link>{' '}
та{' '}
<Link href="/pravyla-povernennia" className="text-[#2d5212] underline underline-offset-2">
правилами повернення квитків
</Link>
</span>
</label>
{globalError && (
<div className="mb-4 rounded-[10px] bg-red-50 px-4 py-3 text-[13px] text-red-600" style={FONT_MONT}>
{globalError}
</div>
)}
<button
type="submit"
disabled={!terms || submitting}
className="w-full rounded-[56px] py-4 text-[16px] font-bold transition-all"
style={{
...FONT_MONT,
background:
terms && !submitting
? 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)'
: '#d0d0d0',
color: terms && !submitting ? '#1a1a1a' : '#999',
cursor: !terms || submitting ? 'not-allowed' : 'pointer',
backgroundSize: '200% auto',
}}
>
{submitting ? 'Обробка…' : `Оплатити ${totalPrice}`}
</button>
{!terms && (
<p className="mt-3 text-center text-[12px] text-[#bbb]" style={FONT_MONT}>
Погодьтесь з умовами, щоб продовжити
</p>
)}
<p className="mt-4 text-center text-[12px] text-[#aaa]" style={FONT_MONT}>
Після оплати квитки надійдуть на email протягом 5 хвилин
</p>
</form>
</div>
</div>
)
}

View file

@ -1,6 +1,6 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { PageHero } from '@/components/ui/PageHero'
import { TariffCardClient } from '@/components/ui/TariffCardClient'
export const metadata: Metadata = {
title: 'Купити квиток — Шуміленд',
@ -93,7 +93,14 @@ export default async function TicketsPage() {
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((tariff) => (
<TariffCard key={tariff.id} tariff={tariff} />
<TariffCardClient
key={tariff.id}
tariffId={String(tariff.id)}
name={tariff.name}
price={tariff.price}
categoryTag={tariff.categoryTag}
icon={tariff.icon}
/>
))}
</div>
</div>
@ -105,38 +112,3 @@ export default async function TicketsPage() {
)
}
function TariffCard({ tariff }: { tariff: Tariff }) {
return (
<div className="flex flex-col gap-6 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]">
{tariff.icon && <span className="text-[40px]">{tariff.icon}</span>}
<div>
<h3
className="text-[20px] leading-tight font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{tariff.name}
</h3>
<p
className="mt-2 text-[36px] font-black text-[#f28b4a]"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{tariff.price}
</p>
</div>
<CheckoutButton tariffId={String(tariff.id)} />
</div>
)
}
function CheckoutButton({ tariffId }: { tariffId: string }) {
return (
<Link
href={`/kvytky/checkout?tariff=${tariffId}`}
className="flex items-center justify-center rounded-[56px] bg-[#f28b4a] py-[10px] text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Придбати
</Link>
)
}

View file

@ -7,6 +7,7 @@ import { Footer } from '@/components/layout/Footer'
import { GoogleAnalytics } from '@/components/analytics/GoogleAnalytics'
import { BinotelWidget } from '@/components/analytics/BinotelWidget'
import { getSiteSettings } from '@/lib/getSiteSettings'
import { CartProvider } from '@/context/CartContext'
const montserrat = Montserrat({
subsets: ['latin', 'cyrillic'],
@ -51,9 +52,11 @@ export default async function FrontendLayout({ children }: { children: React.Rea
>
{settings.ga4Id && <GoogleAnalytics measurementId={settings.ga4Id} />}
{settings.binotelId && <BinotelWidget widgetId={settings.binotelId} />}
<Header />
<main className="flex-1">{children}</main>
<Footer />
<CartProvider>
<Header />
<main className="flex-1">{children}</main>
<Footer />
</CartProvider>
</div>
)
}

View file

@ -1,6 +1,6 @@
import Link from 'next/link'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { PricingCardClient } from './PricingCardClient'
interface PricingBlockProps {
title?: string | null
@ -61,40 +61,14 @@ export async function PricingBlockComponent({
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{tariffs.map((t) => (
<div
<PricingCardClient
key={t.id}
className="flex flex-col gap-4 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]"
>
<div>
<p
className="mb-2 text-[14px] font-bold text-[#f28b4a] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{t.category_tag}
</p>
<h3
className="text-[20px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{t.display_name ?? t.last_synced_name}
</h3>
</div>
{t.last_synced_price != null && (
<p
className="text-[48px] leading-none font-black text-white"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{t.last_synced_price} <span className="text-[24px] font-bold">грн</span>
</p>
)}
<Link
href="/kvytky"
className="mt-auto rounded-[56px] bg-[#f28b4a] px-[30px] py-[10px] text-center text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{ctaLabel ?? 'Купити квиток'}
</Link>
</div>
tariffId={t.ezy_id != null ? String(t.ezy_id) : ''}
name={t.display_name ?? t.last_synced_name ?? ''}
price={t.last_synced_price ?? null}
categoryTag={t.category_tag ?? null}
ctaLabel={ctaLabel}
/>
))}
</div>
)}

View file

@ -0,0 +1,77 @@
'use client'
import { useState } from 'react'
import { useCart } from '@/context/CartContext'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const FONT_INTER = { fontFamily: 'var(--font-inter, Inter), sans-serif' }
interface PricingCardClientProps {
tariffId: string // ezy_id as string — used by checkout API
name: string
price: number | null
categoryTag: string | null
ctaLabel?: string | null
}
export function PricingCardClient({
tariffId,
name,
price,
categoryTag,
ctaLabel,
}: PricingCardClientProps) {
const { addItem } = useCart()
const [added, setAdded] = useState(false)
function handleAdd() {
addItem({
tariffId,
name,
price: price ?? 0,
categoryTag: categoryTag ?? '',
})
setAdded(true)
setTimeout(() => setAdded(false), 1500)
}
return (
<div className="flex flex-col gap-4 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]">
<div>
{categoryTag && (
<p
className="mb-2 text-[14px] font-bold text-[#f28b4a] uppercase"
style={FONT_MONT}
>
{categoryTag}
</p>
)}
<h3 className="text-[20px] font-bold text-white" style={FONT_MONT}>
{name}
</h3>
</div>
{price != null && (
<p className="text-[48px] leading-none font-black text-white" style={FONT_INTER}>
{price} <span className="text-[24px] font-bold">грн</span>
</p>
)}
<button
onClick={handleAdd}
disabled={!tariffId}
className="mt-auto rounded-[56px] px-[30px] py-[10px] text-center text-[16px] font-bold transition-all"
style={{
...FONT_MONT,
background: added
? '#4caf50'
: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
color: added ? '#fff' : '#1a1a1a',
backgroundSize: '200% auto',
cursor: tariffId ? 'pointer' : 'not-allowed',
opacity: tariffId ? 1 : 0.5,
}}
>
{added ? '✓ Додано' : (ctaLabel ?? 'До кошика')}
</button>
</div>
)
}

View file

@ -6,6 +6,7 @@ import Link from 'next/link'
import { useState } from 'react'
import { NavLink } from '@/components/ui/NavLink'
import { BtnPrimary } from '@/components/ui/BtnPrimary'
import { CartIcon } from '@/components/ui/CartIcon'
export interface NavLinkItem {
label: string
@ -113,8 +114,9 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref, logo }: HeaderClient
</ul>
</nav>
{/* CTA — desktop */}
<div className="hidden lg:block">
{/* Cart + CTA — desktop */}
<div className="hidden items-center gap-3 lg:flex">
<CartIcon />
<BtnPrimary href={ctaHref}>{ctaLabel}</BtnPrimary>
</div>
@ -185,7 +187,8 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref, logo }: HeaderClient
)}
</li>
))}
<li>
<li className="flex items-center gap-4">
<CartIcon />
<Link
href={ctaHref}
onClick={() => setMenuOpen(false)}

View file

@ -1,90 +1,135 @@
import { BtnPrimary } from '@/components/ui/BtnPrimary'
'use client'
import { useEffect, useState } from 'react'
import { useCart } from '@/context/CartContext'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
interface TicketCard {
label: string
price: string
per: string
note?: string
interface Tariff {
id: number
name: string
price: number
categoryTag: string
icon?: string | null
sort: number
}
const SINGLE_TICKETS: TicketCard[] = [
{ label: 'Диво Ліс', price: '250 грн', per: 'за 1 людину' },
{ label: 'Вхід до ДиноПарку', price: '300 грн', per: 'за 1 людину' },
{
label: 'Звичайна екскурсія',
price: '150 грн',
per: 'за 1 людину',
note: 'Екскурсійна група — від 5 людей\nПриблизний час екскурсії — 30 хвилин',
},
{ label: 'Палеонтологічна екскурсія', price: '300 грн', per: 'за 1 людину' },
{ label: 'Динородео', price: '50 грн', per: 'сеанс' },
]
interface TicketCardProps {
tariff: Tariff
}
const COMBO_TICKETS: TicketCard[] = [
{ label: '', price: '600 грн', per: 'Комбо на людину' },
{
label: '',
price: '1500 грн',
per: 'Комбо на 3 людини',
note: '(мама та/або тато та їхні діти до 14 років)',
},
{
label: '',
price: '1800 грн',
per: 'Комбо на 4 людини',
note: '(мама та/або тато та їхні діти до 14 років)',
},
{
label: '',
price: '2000 грн',
per: 'Комбо на 5 людей',
note: '(мама та/або тато та їхні діти до 14 років)',
},
]
function TicketCard({ tariff }: TicketCardProps) {
const { addItem } = useCart()
const [count, setCount] = useState(1)
const [added, setAdded] = useState(false)
function handleAdd() {
for (let i = 0; i < count; i++) {
addItem({
tariffId: String(tariff.id),
name: tariff.name,
price: tariff.price,
categoryTag: tariff.categoryTag,
icon: tariff.icon ?? undefined,
})
}
setAdded(true)
setCount(1)
setTimeout(() => setAdded(false), 1500)
}
function Ticket({ label, price, per, note }: TicketCard) {
return (
<div
className="flex flex-col rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fdf2e8' }}
>
<div className="flex flex-col gap-2 text-center">
{label && (
<p
className="text-[13px] leading-[1.5] font-bold text-[#f28b4a] uppercase"
style={FONT_MONT}
>
{label}
</p>
)}
{tariff.icon && <span className="text-[28px]">{tariff.icon}</span>}
<div className="border-t border-[#c8e8b0]" />
<p
className="text-[32px] leading-[1.3] font-black text-[#272727] lg:text-[42px]"
className="text-[32px] leading-[1.3] font-black text-[#272727] lg:text-[40px]"
style={FONT_MONT}
>
{price}
{tariff.price}
</p>
<p className="text-[13px] leading-[1.5] font-bold text-[#272727]" style={FONT_MONT}>
{per}
<p className="text-[14px] leading-[1.5] font-bold text-[#272727]" style={FONT_MONT}>
{tariff.name}
</p>
{note && (
<p className="text-[12px] leading-[1.5] text-[#4a4a4a]" style={FONT_MONT}>
{note}
</p>
)}
</div>
<div className="mt-auto pt-5">
<BtnPrimary href="/kvytky?category=dyvolis" className="w-full justify-center !text-[16px]">
Забронювати пригоду
</BtnPrimary>
{/* Counter + Add */}
<div className="mt-auto pt-5 flex flex-col gap-3">
<div className="flex items-center justify-center gap-3">
<button
onClick={() => setCount((c) => Math.max(1, c - 1))}
aria-label="Зменшити кількість"
className="flex h-9 w-9 items-center justify-center rounded-full text-[20px] font-bold text-white transition-opacity hover:opacity-80"
style={{ background: '#f28b4a' }}
>
</button>
<span className="min-w-[28px] text-center text-[18px] font-bold text-[#272727]" style={FONT_MONT}>
{count}
</span>
<button
onClick={() => setCount((c) => Math.min(10, c + 1))}
aria-label="Збільшити кількість"
className="flex h-9 w-9 items-center justify-center rounded-full text-[20px] font-bold text-white transition-opacity hover:opacity-80"
style={{ background: '#f28b4a' }}
>
+
</button>
</div>
<button
onClick={handleAdd}
className="w-full rounded-[56px] py-[10px] text-[15px] font-bold transition-all"
style={{
...FONT_MONT,
background: added
? '#4caf50'
: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
color: added ? '#fff' : '#1a1a1a',
backgroundSize: '200% auto',
}}
>
{added ? '✓ Додано' : '+ До кошика'}
</button>
</div>
</div>
)
}
function SkeletonCard() {
return (
<div
className="flex flex-col rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)] animate-pulse"
style={{ background: '#fdf2e8', minHeight: 180 }}
>
<div className="h-4 w-3/4 rounded bg-[#e8d8c4] mb-3 mx-auto" />
<div className="h-8 w-1/2 rounded bg-[#e8d8c4] mb-2 mx-auto" />
<div className="h-4 w-2/3 rounded bg-[#e8d8c4] mx-auto mt-auto" />
</div>
)
}
export function DyvoLisTickets() {
const [tariffs, setTariffs] = useState<Tariff[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/tickets/tariffs')
.then((r) => r.json())
.then((data: { tariffs?: Tariff[] }) => {
const dyvolis = (data.tariffs ?? []).filter((t) => t.categoryTag === 'dyvolis')
setTariffs(dyvolis)
})
.catch(() => {/* show nothing on error — section still renders */})
.finally(() => setLoading(false))
}, [])
const single = tariffs.filter((t) => !t.name.toLowerCase().includes('комбо') && !t.name.toLowerCase().includes('combo'))
const combo = tariffs.filter((t) => t.name.toLowerCase().includes('комбо') || t.name.toLowerCase().includes('combo'))
return (
<section className="relative overflow-hidden">
{/* Dark green background */}
@ -123,31 +168,37 @@ export function DyvoLisTickets() {
Вартість квитка
</h3>
<div className="mb-10 grid grid-cols-1 gap-4 sm:grid-cols-2">
{SINGLE_TICKETS.map((t, i) => (
<Ticket key={i} {...t} />
))}
{loading
? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
: (single.length > 0 ? single : tariffs).map((t) => (
<TicketCard key={t.id} tariff={t} />
))}
</div>
{/* Combo header */}
<div className="mb-6 flex flex-col gap-2">
<h3
className="text-[22px] leading-[1.4] font-bold text-white uppercase lg:text-[28px]"
style={FONT_MONT}
>
Комбо
</h3>
<p
className="text-[16px] leading-[1.4] font-semibold text-white/80 lg:text-[20px]"
style={FONT_MONT}
>
Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{COMBO_TICKETS.map((t, i) => (
<Ticket key={i} {...t} />
))}
</div>
{/* Combo section — only if we have combo tariffs */}
{!loading && combo.length > 0 && (
<>
<div className="mb-6 flex flex-col gap-2">
<h3
className="text-[22px] leading-[1.4] font-bold text-white uppercase lg:text-[28px]"
style={FONT_MONT}
>
Комбо
</h3>
<p
className="text-[16px] leading-[1.4] font-semibold text-white/80 lg:text-[20px]"
style={FONT_MONT}
>
Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{combo.map((t) => (
<TicketCard key={t.id} tariff={t} />
))}
</div>
</>
)}
</div>
</section>
)

View file

@ -81,7 +81,8 @@ export function Locations({ data, title }: LocationsProps) {
getMediaUrl(loc.image) ??
FALLBACK_IMAGES[loc.slug] ??
'/images/figma/loc-dinopark.webp',
href: loc.slug === 'dyvolis' ? '/lokatsii/dyvolis' : (loc.href ?? `/lokatsii#${loc.slug}`),
href:
loc.slug === 'dyvolis' ? '/lokatsii/dyvolis' : (loc.href ?? `/lokatsii#${loc.slug}`),
}))
: STATIC_LOCATIONS

View file

@ -0,0 +1,45 @@
'use client'
import Link from 'next/link'
import { useCart } from '@/context/CartContext'
export function CartIcon() {
const { totalCount } = useCart()
return (
<Link
href="/korzyna"
aria-label={`Кошик${totalCount > 0 ? `, ${totalCount} позицій` : ''}`}
className="relative flex h-10 w-10 items-center justify-center text-white transition-opacity hover:opacity-80"
>
<svg
width="26"
height="26"
viewBox="0 0 26 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M3 3H5.5L7.2 14.39C7.37 15.28 8.14 15.93 9.04 15.93H19.49C20.37 15.93 21.12 15.31 21.31 14.45L22.97 7H6"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="10" cy="20.5" r="1.5" fill="white" />
<circle cx="19" cy="20.5" r="1.5" fill="white" />
</svg>
{totalCount > 0 && (
<span
aria-hidden="true"
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white"
style={{ background: '#f28b4a', fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{totalCount > 99 ? '99+' : totalCount}
</span>
)}
</Link>
)
}

View file

@ -0,0 +1,82 @@
'use client'
import { useState } from 'react'
import { useCart } from '@/context/CartContext'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const FONT_INTER = { fontFamily: 'var(--font-inter, Inter), sans-serif' }
interface TariffCardClientProps {
tariffId: string
name: string
price: number
categoryTag: string
icon?: string | null
}
export function TariffCardClient({ tariffId, name, price, categoryTag, icon }: TariffCardClientProps) {
const { addItem } = useCart()
const [count, setCount] = useState(1)
const [added, setAdded] = useState(false)
function handleAdd() {
for (let i = 0; i < count; i++) {
addItem({ tariffId, name, price, categoryTag, icon: icon ?? undefined })
}
setAdded(true)
setCount(1)
setTimeout(() => setAdded(false), 1500)
}
return (
<div className="flex flex-col gap-6 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]">
{icon && <span className="text-[40px]">{icon}</span>}
<div>
<h3 className="text-[20px] leading-tight font-bold text-white" style={FONT_MONT}>
{name}
</h3>
<p className="mt-2 text-[36px] font-black text-[#f28b4a]" style={FONT_INTER}>
{price}
</p>
</div>
{/* Counter */}
<div className="flex items-center gap-3">
<button
onClick={() => setCount((c) => Math.max(1, c - 1))}
aria-label="Зменшити кількість"
className="flex h-9 w-9 items-center justify-center rounded-full text-[20px] font-bold text-white transition-opacity hover:opacity-80 active:opacity-60"
style={{ background: 'rgba(255,255,255,0.15)' }}
>
</button>
<span className="min-w-[28px] text-center text-[18px] font-bold text-white" style={FONT_MONT}>
{count}
</span>
<button
onClick={() => setCount((c) => Math.min(10, c + 1))}
aria-label="Збільшити кількість"
className="flex h-9 w-9 items-center justify-center rounded-full text-[20px] font-bold text-white transition-opacity hover:opacity-80 active:opacity-60"
style={{ background: 'rgba(255,255,255,0.15)' }}
>
+
</button>
</div>
<button
onClick={handleAdd}
className="flex items-center justify-center rounded-[56px] py-[10px] text-[16px] font-bold transition-all"
style={{
...FONT_MONT,
background: added
? '#4caf50'
: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
color: added ? '#fff' : '#1a1a1a',
backgroundSize: '200% auto',
}}
>
{added ? '✓ Додано' : '+ До кошика'}
</button>
</div>
)
}

120
src/context/CartContext.tsx Normal file
View file

@ -0,0 +1,120 @@
'use client'
import { createContext, useContext, useEffect, useReducer } from 'react'
import type { ReactNode } from 'react'
import type { CartItem } from '@/lib/cart'
import { loadCart, saveCart } from '@/lib/cart'
// ── State & Actions ──────────────────────────────────────────────
interface CartState {
items: CartItem[]
hydrated: boolean
}
type CartAction =
| { type: 'HYDRATE'; items: CartItem[] }
| { type: 'ADD_ITEM'; item: Omit<CartItem, 'count'> }
| { type: 'REMOVE_ITEM'; tariffId: string }
| { type: 'UPDATE_COUNT'; tariffId: string; count: number }
| { type: 'CLEAR_CART' }
function reducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'HYDRATE':
return { items: action.items, hydrated: true }
case 'ADD_ITEM': {
const existing = state.items.find((i) => i.tariffId === action.item.tariffId)
if (existing) {
return {
...state,
items: state.items.map((i) =>
i.tariffId === action.item.tariffId ? { ...i, count: i.count + 1 } : i,
),
}
}
return { ...state, items: [...state.items, { ...action.item, count: 1 }] }
}
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter((i) => i.tariffId !== action.tariffId) }
case 'UPDATE_COUNT': {
if (action.count <= 0) {
return { ...state, items: state.items.filter((i) => i.tariffId !== action.tariffId) }
}
return {
...state,
items: state.items.map((i) =>
i.tariffId === action.tariffId ? { ...i, count: action.count } : i,
),
}
}
case 'CLEAR_CART':
return { ...state, items: [] }
default:
return state
}
}
// ── Context ──────────────────────────────────────────────────────
interface CartContextValue {
items: CartItem[]
totalCount: number
totalPrice: number
hydrated: boolean
addItem: (item: Omit<CartItem, 'count'>) => void
removeItem: (tariffId: string) => void
updateCount: (tariffId: string, count: number) => void
clearCart: () => void
}
const CartContext = createContext<CartContextValue | null>(null)
// ── Provider ─────────────────────────────────────────────────────
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, { items: [], hydrated: false })
// Read from localStorage on first client render
useEffect(() => {
dispatch({ type: 'HYDRATE', items: loadCart() })
}, [])
// Persist to localStorage on every change after hydration
useEffect(() => {
if (state.hydrated) saveCart(state.items)
}, [state.items, state.hydrated])
const totalCount = state.items.reduce((s, i) => s + i.count, 0)
const totalPrice = state.items.reduce((s, i) => s + i.price * i.count, 0)
return (
<CartContext.Provider
value={{
items: state.items,
totalCount,
totalPrice,
hydrated: state.hydrated,
addItem: (item) => 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' }),
}}
>
{children}
</CartContext.Provider>
)
}
// ── Hook ─────────────────────────────────────────────────────────
export function useCart(): CartContextValue {
const ctx = useContext(CartContext)
if (!ctx) throw new Error('useCart must be used inside <CartProvider>')
return ctx
}

28
src/lib/cart.ts Normal file
View file

@ -0,0 +1,28 @@
export const CART_KEY = 'shumiland_cart'
export interface CartItem {
tariffId: string
name: string
price: number
count: number
categoryTag: string
icon?: string
}
export function loadCart(): CartItem[] {
if (typeof window === 'undefined') return []
try {
const raw = localStorage.getItem(CART_KEY)
return raw ? (JSON.parse(raw) as CartItem[]) : []
} catch {
return []
}
}
export function saveCart(items: CartItem[]): void {
try {
localStorage.setItem(CART_KEY, JSON.stringify(items))
} catch {
// ignore — localStorage unavailable
}
}