feat(cart): add persistent cart flow with /korzyna checkout page
- 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:
parent
5035370cb2
commit
e4b259afdc
12 changed files with 841 additions and 162 deletions
323
src/app/(frontend)/korzyna/page.tsx
Normal file
323
src/app/(frontend)/korzyna/page.tsx
Normal 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}>
|
||||
Ім'я *
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
77
src/components/blocks/PricingCardClient.tsx
Normal file
77
src/components/blocks/PricingCardClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
45
src/components/ui/CartIcon.tsx
Normal file
45
src/components/ui/CartIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
src/components/ui/TariffCardClient.tsx
Normal file
82
src/components/ui/TariffCardClient.tsx
Normal 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
120
src/context/CartContext.tsx
Normal 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
28
src/lib/cart.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue