feat: Phase 6 — Ticket payment (ezy.com.ua → Monobank)

- src/lib/ezy.ts — getTariffs() (5min cache), createPayment() via multipart/form-data
- src/lib/qr.ts — generateQRDataUrl(), getTicketVerifyUrl()
- src/lib/email.ts — Nodemailer HTML confirmation email
- src/app/(frontend)/payments/page.tsx — 3-step checkout (select → buyer → pay)
- src/app/(frontend)/thank-you/page.tsx — simple confirmation (ezy handles ticket display)
- src/app/api/tickets/create/route.ts — Order(PENDING) → ezy.createPayment() → paymentUrl
- src/app/api/tickets/webhook/route.ts — mark PAID, create Ticket records with QR URLs
- src/app/api/tickets/verify/[code]/route.ts — staff scanner endpoint, marks isUsed
- Fix: orderNumber/ticketCode not required in schema (hooks generate them)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-04 17:27:22 +01:00
parent 67140a9ec4
commit eafa994484
11 changed files with 751 additions and 3 deletions

View file

@ -0,0 +1,272 @@
'use client'
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { cn } from '@/lib/utils'
import { Minus, Plus, Ticket, ArrowRight, ArrowLeft, CreditCard, Loader2 } from 'lucide-react'
interface TicketCategory {
id: string
name: string
price: number
location: string
ageGroup?: string
isFree?: boolean
freeCondition?: string
isActive?: boolean
}
const BuyerSchema = z.object({
customerName: z.string().min(2, 'Вкажіть ваше ім\'я'),
customerEmail: z.string().email('Невірний email'),
customerPhone: z.string().optional(),
})
type BuyerData = z.infer<typeof BuyerSchema>
const STEPS = ['Квитки', 'Ваші дані', 'Оплата']
export default function PaymentsPage() {
const [step, setStep] = useState(0)
const [categories, setCategories] = useState<TicketCategory[]>([])
const [quantities, setQuantities] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const { register, handleSubmit, getValues, formState: { errors } } = useForm<BuyerData>({
resolver: zodResolver(BuyerSchema),
})
// Fetch TicketsConfig
useEffect(() => {
fetch('/api/globals/tickets-config')
.then((r) => r.json())
.then((data) => {
const cats: TicketCategory[] = (data?.categories ?? []).filter(
(c: TicketCategory) => c.isActive && !c.isFree,
)
setCategories(cats)
setLoading(false)
})
.catch(() => setLoading(false))
}, [])
const setQty = (id: string, delta: number) => {
setQuantities((q) => {
const next = Math.max(0, (q[id] ?? 0) + delta)
return { ...q, [id]: next }
})
}
const selectedItems = categories.filter((c) => (quantities[c.id] ?? 0) > 0)
const totalAmount = selectedItems.reduce(
(sum, c) => sum + c.price * (quantities[c.id] ?? 0),
0,
)
const totalTickets = selectedItems.reduce((sum, c) => sum + (quantities[c.id] ?? 0), 0)
const onPay = async (buyer: BuyerData) => {
setSubmitting(true)
setError('')
try {
const res = await fetch('/api/tickets/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...buyer,
items: selectedItems.map((c) => ({
categoryName: c.name,
ezyTariffId: c.id,
quantity: quantities[c.id] ?? 0,
price: c.price,
})),
}),
})
const json = await res.json()
if (!res.ok || !json.paymentUrl) {
throw new Error(json.error ?? 'Помилка створення платежу')
}
// Redirect to Monobank
window.location.href = json.paymentUrl
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Щось пішло не так. Спробуйте ще раз.')
setSubmitting(false)
}
}
return (
<div className="min-h-[70vh] py-16 bg-gray-50">
<div className="container max-w-2xl">
<h1 className="text-3xl font-extrabold text-brand-green text-center mb-2">Купити квитки</h1>
<p className="text-center text-gray-500 mb-8">Оплата через Monobank безпечно та швидко</p>
{/* Step indicator */}
<div className="flex items-center justify-center gap-2 mb-10">
{STEPS.map((s, i) => (
<div key={s} className="flex items-center gap-2">
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-colors',
i < step ? 'bg-brand-green text-white' :
i === step ? 'bg-brand-orange text-white' :
'bg-gray-200 text-gray-500',
)}>
{i < step ? '✓' : i + 1}
</div>
<span className={cn('text-sm hidden sm:block', i === step ? 'font-semibold text-brand-black' : 'text-gray-500')}>
{s}
</span>
{i < STEPS.length - 1 && <div className="w-6 h-0.5 bg-gray-200" />}
</div>
))}
</div>
<div className="bg-white rounded-2xl shadow-sm p-6">
{/* Step 0: Select tickets */}
{step === 0 && (
<div>
<h2 className="text-xl font-bold text-brand-black mb-6">Оберіть квитки</h2>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 size={32} className="animate-spin text-brand-green" />
</div>
) : categories.length === 0 ? (
<p className="text-center text-gray-400 py-8">Квитки тимчасово недоступні</p>
) : (
<div className="divide-y divide-gray-100">
{categories.map((cat) => (
<div key={cat.id} className="flex items-center justify-between py-4">
<div>
<p className="font-semibold text-brand-black">{cat.name}</p>
<p className="text-brand-orange font-bold">{cat.price} грн</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setQty(cat.id, -1)}
disabled={(quantities[cat.id] ?? 0) === 0}
className="w-9 h-9 rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-30 flex items-center justify-center transition-colors"
>
<Minus size={16} />
</button>
<span className="w-6 text-center font-bold tabular-nums">
{quantities[cat.id] ?? 0}
</span>
<button
onClick={() => setQty(cat.id, 1)}
className="w-9 h-9 rounded-lg bg-brand-green/10 hover:bg-brand-green/20 flex items-center justify-center transition-colors"
>
<Plus size={16} className="text-brand-green" />
</button>
</div>
</div>
))}
</div>
)}
{totalTickets > 0 && (
<div className="mt-6 pt-4 border-t flex items-center justify-between">
<div>
<span className="text-gray-500 text-sm">{totalTickets} квит. × ...</span>
<p className="text-xl font-extrabold text-brand-green">{totalAmount} грн</p>
</div>
<button
onClick={() => setStep(1)}
className="flex items-center gap-2 px-6 py-3 bg-brand-orange text-white font-bold rounded-xl hover:bg-brand-orange/90 transition-colors"
>
Далі <ArrowRight size={16} />
</button>
</div>
)}
</div>
)}
{/* Step 1: Buyer info */}
{step === 1 && (
<form onSubmit={handleSubmit(() => setStep(2))}>
<h2 className="text-xl font-bold text-brand-black mb-6">Ваші дані</h2>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium mb-1">Ім'я та прізвище *</label>
<input {...register('customerName')} className={cn('w-full px-4 py-2.5 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-brand-green/20', errors.customerName ? 'border-red-300' : 'border-gray-200')} placeholder="Іваненко Іван" />
{errors.customerName && <p className="text-xs text-red-500 mt-1">{errors.customerName.message}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Email *</label>
<input {...register('customerEmail')} type="email" className={cn('w-full px-4 py-2.5 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-brand-green/20', errors.customerEmail ? 'border-red-300' : 'border-gray-200')} placeholder="your@email.com" />
{errors.customerEmail && <p className="text-xs text-red-500 mt-1">{errors.customerEmail.message}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Телефон</label>
<input {...register('customerPhone')} type="tel" className="w-full px-4 py-2.5 rounded-lg border border-gray-200 text-sm focus:outline-none focus:ring-2 focus:ring-brand-green/20" placeholder="+380 XX XXX XX XX" />
</div>
</div>
<div className="flex gap-3 mt-6">
<button type="button" onClick={() => setStep(0)} className="flex items-center gap-1 px-4 py-3 text-gray-600 hover:text-brand-black transition-colors">
<ArrowLeft size={16} /> Назад
</button>
<button type="submit" className="flex-1 flex items-center justify-center gap-2 py-3 bg-brand-orange text-white font-bold rounded-xl hover:bg-brand-orange/90 transition-colors">
Переглянути замовлення <ArrowRight size={16} />
</button>
</div>
</form>
)}
{/* Step 2: Review + pay */}
{step === 2 && (
<div>
<h2 className="text-xl font-bold text-brand-black mb-6">Підтвердження</h2>
<div className="bg-gray-50 rounded-xl p-4 mb-4">
<h3 className="font-semibold text-sm text-gray-600 mb-3 uppercase tracking-wide">Квитки</h3>
{selectedItems.map((cat) => (
<div key={cat.id} className="flex justify-between py-1.5 text-sm">
<span className="text-brand-black">{cat.name} × {quantities[cat.id]}</span>
<span className="font-semibold">{cat.price * (quantities[cat.id] ?? 0)} грн</span>
</div>
))}
<div className="border-t mt-2 pt-2 flex justify-between font-bold">
<span>Разом</span>
<span className="text-brand-orange text-lg">{totalAmount} грн</span>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-4 mb-6">
<h3 className="font-semibold text-sm text-gray-600 mb-2 uppercase tracking-wide">Покупець</h3>
<p className="text-sm font-medium">{getValues('customerName')}</p>
<p className="text-sm text-gray-600">{getValues('customerEmail')}</p>
{getValues('customerPhone') && <p className="text-sm text-gray-600">{getValues('customerPhone')}</p>}
</div>
{error && <p className="text-sm text-red-500 mb-4 bg-red-50 rounded-lg px-4 py-3">{error}</p>}
<div className="flex gap-3">
<button onClick={() => setStep(1)} className="flex items-center gap-1 px-4 py-3 text-gray-600 hover:text-brand-black transition-colors">
<ArrowLeft size={16} /> Назад
</button>
<button
onClick={handleSubmit(onPay)}
disabled={submitting}
className={cn('flex-1 flex items-center justify-center gap-2 py-3.5 bg-brand-green text-white font-bold rounded-xl hover:bg-brand-green-mid transition-colors shadow hover:shadow-lg', submitting && 'opacity-70 cursor-not-allowed')}
>
{submitting ? <Loader2 size={18} className="animate-spin" /> : <CreditCard size={18} />}
{submitting ? 'Переадресація...' : `Оплатити ${totalAmount} грн`}
</button>
</div>
<p className="text-xs text-center text-gray-400 mt-4 flex items-center justify-center gap-1">
<Ticket size={12} /> Квиток буде доступний на сторінці оплати Monobank
</p>
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,41 @@
import Link from 'next/link'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Дякуємо за покупку',
robots: { index: false },
}
export default function ThankYouPage() {
return (
<div className="min-h-[60vh] flex items-center justify-center py-20">
<div className="container max-w-lg text-center">
<div className="text-6xl mb-6">🎉</div>
<h1 className="text-3xl font-extrabold text-brand-green mb-4">
Дякуємо за покупку!
</h1>
<p className="text-lg text-gray-600 mb-6 leading-relaxed">
Ваш платіж прийнято. Квиток доступний у вашому кабінеті на сайті Monobank.
</p>
<div className="bg-brand-yellow/30 rounded-2xl p-5 mb-8 text-sm text-brand-black leading-relaxed">
<p className="font-semibold mb-1">🦕 Чекаємо вас у Шуміленді!</p>
<p>Покажіть QR-код з квитка на вході до атракціону.</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href="/"
className="px-6 py-3 bg-brand-green text-white font-bold rounded-xl hover:bg-brand-green-mid transition-colors"
>
На головну
</Link>
<Link
href="/events"
className="px-6 py-3 border-2 border-brand-green text-brand-green font-bold rounded-xl hover:bg-brand-green/5 transition-colors"
>
Найближчі події
</Link>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { cookies } from 'next/headers'
import { getPayloadClient } from '@/lib/payload'
import { getUtmFromCookies, getGoogleClientId } from '@/lib/utm'
import { createPayment } from '@/lib/ezy'
const CreateOrderSchema = z.object({
customerName: z.string().min(2),
customerEmail: z.string().email(),
customerPhone: z.string().optional(),
items: z
.array(
z.object({
categoryName: z.string(),
ezyTariffId: z.string(),
quantity: z.number().int().positive(),
price: z.number().positive(),
}),
)
.min(1),
})
export async function POST(req: NextRequest) {
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const parsed = CreateOrderSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Validation failed', issues: parsed.error.issues }, { status: 422 })
}
const { customerName, customerEmail, customerPhone, items } = parsed.data
const totalAmount = items.reduce((sum, i) => sum + i.price * i.quantity, 0)
const cookieStore = await cookies()
const utm = getUtmFromCookies(cookieStore)
const googleClientId = getGoogleClientId(cookieStore)
const payload = await getPayloadClient()
// Create PENDING order
const order = await payload.create({
collection: 'orders',
draft: false,
data: {
customerName,
customerEmail,
customerPhone: customerPhone || undefined,
status: 'pending',
items: items.map((i) => ({
categoryName: i.categoryName,
quantity: i.quantity,
price: i.price,
ezyTariffId: i.ezyTariffId,
})),
totalAmount,
utmSource: utm.utmSource || undefined,
utmMedium: utm.utmMedium || undefined,
utmCampaign: utm.utmCampaign || undefined,
utmContent: utm.utmContent || undefined,
utmTerm: utm.utmTerm || undefined,
googleClientId: googleClientId || undefined,
},
})
// Call ezy.com.ua to get Monobank payment URL
const payment = await createPayment({
email: customerEmail,
orderReference: order.orderNumber!,
items: items.flatMap((i) =>
Array.from({ length: i.quantity }, () => ({
tariff_id: i.ezyTariffId,
count: 1,
})),
),
})
// Save payment URL to order
await payload.update({
collection: 'orders',
id: order.id,
data: { ezyPaymentUrl: payment.url },
})
return NextResponse.json({ paymentUrl: payment.url, orderNumber: order.orderNumber }, { status: 201 })
}

View file

@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload'
type Props = { params: Promise<{ code: string }> }
/**
* Ticket scanner endpoint.
* GET /api/tickets/verify/{ticketCode}
*
* Returns ticket status. Staff scans QR with their phone camera.
* On first scan: marks ticket as used.
*/
export async function GET(_req: NextRequest, { params }: Props) {
const { code } = await params
if (!code) {
return NextResponse.json({ valid: false, error: 'Missing code' }, { status: 400 })
}
const payload = await getPayloadClient()
const { docs } = await payload.find({
collection: 'tickets',
where: { ticketCode: { equals: code.toUpperCase() } },
depth: 1,
limit: 1,
})
if (!docs.length) {
return NextResponse.json({ valid: false, error: 'Ticket not found' }, { status: 404 })
}
const ticket = docs[0]
if (ticket.isUsed) {
const usedAt = ticket.usedAt ? new Date(ticket.usedAt).toLocaleString('uk-UA') : 'невідомо'
return NextResponse.json({
valid: false,
error: 'Квиток вже використано',
usedAt,
categoryName: ticket.categoryName,
ticketCode: ticket.ticketCode,
})
}
// Mark as used
await payload.update({
collection: 'tickets',
id: ticket.id,
data: { isUsed: true, usedAt: new Date().toISOString() },
})
return NextResponse.json({
valid: true,
ticketCode: ticket.ticketCode,
categoryName: ticket.categoryName,
message: '✅ Квиток дійсний — прохід дозволено',
})
}

View file

@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload'
import { getTicketVerifyUrl } from '@/lib/qr'
/**
* ezy.com.ua payment success webhook.
* Called when Monobank payment is confirmed.
*
* Expected body: { reference: string, status: 'success'|'failed', amount: number }
* Exact format depends on ezy.com.ua docs adjust field names as needed.
*/
export async function POST(req: NextRequest) {
let body: Record<string, unknown>
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const reference = (body.reference ?? body.order_reference ?? body.orderId) as string | undefined
const status = (body.status ?? body.payment_status) as string | undefined
if (!reference) {
return NextResponse.json({ error: 'Missing reference' }, { status: 400 })
}
// Only process successful payments
if (status && status !== 'success' && status !== 'paid') {
return NextResponse.json({ ok: true, skipped: true })
}
const payload = await getPayloadClient()
// Find the pending order
const { docs } = await payload.find({
collection: 'orders',
where: { orderNumber: { equals: reference } },
depth: 0,
limit: 1,
})
if (!docs.length) {
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
}
const order = docs[0]
// Idempotency — skip if already paid
if (order.status === 'paid') {
return NextResponse.json({ ok: true, idempotent: true })
}
// Mark order as paid
await payload.update({
collection: 'orders',
id: order.id,
data: { status: 'paid' },
})
// Create Ticket records (one per item × quantity)
const ticketIds: number[] = []
for (const item of order.items ?? []) {
for (let i = 0; i < item.quantity; i++) {
const ticket = await payload.create({
collection: 'tickets',
draft: false,
data: {
order: order.id,
categoryName: item.categoryName,
price: item.price,
isUsed: false,
},
})
// Save verify URL (QR points to our scanner endpoint)
const qrUrl = getTicketVerifyUrl(ticket.ticketCode!)
await payload.update({
collection: 'tickets',
id: ticket.id,
data: { qrCodeUrl: qrUrl },
})
ticketIds.push(ticket.id)
}
}
// Link tickets to order
if (ticketIds.length) {
await payload.update({
collection: 'orders',
id: order.id,
data: { tickets: ticketIds },
})
}
return NextResponse.json({ ok: true, ticketsCreated: ticketIds.length })
}

View file

@ -43,7 +43,6 @@ export const Orders: CollectionConfig = {
name: 'orderNumber',
type: 'text',
label: 'Номер замовлення',
required: true,
unique: true,
index: true,
admin: {

View file

@ -44,7 +44,6 @@ export const Tickets: CollectionConfig = {
name: 'ticketCode',
type: 'text',
label: 'Код квитка',
required: true,
unique: true,
index: true,
admin: {

84
src/lib/email.ts Normal file
View file

@ -0,0 +1,84 @@
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
export interface TicketEmailParams {
to: string
customerName: string
orderNumber: string
items: { categoryName: string; quantity: number; price: number }[]
totalAmount: number
tickets: { ticketCode: string; categoryName: string; qrDataUrl: string }[]
pdfBuffer: Buffer
}
export async function sendTicketEmail(params: TicketEmailParams): Promise<void> {
const { to, customerName, orderNumber, items, totalAmount, tickets, pdfBuffer } = params
const ticketsHtml = tickets
.map(
(t) => `
<div style="border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:16px;text-align:center;">
<p style="font-size:14px;color:#6b7280;margin:0 0 8px;">${t.categoryName}</p>
<img src="${t.qrDataUrl}" alt="QR ${t.ticketCode}" style="width:160px;height:160px;" />
<p style="font-size:12px;font-family:monospace;color:#374151;margin:8px 0 0;">${t.ticketCode}</p>
</div>`,
)
.join('')
const html = `
<!DOCTYPE html>
<html lang="uk">
<head><meta charset="utf-8"><title>Квитки Шуміленд</title></head>
<body style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#231F20;">
<div style="background:#223D17;padding:24px;border-radius:16px;text-align:center;margin-bottom:24px;">
<h1 style="color:#FCDD67;font-size:28px;margin:0;">🦕 Шуміленд</h1>
<p style="color:#fff;margin:8px 0 0;font-size:14px;">Дякуємо за покупку!</p>
</div>
<p>Привіт, <strong>${customerName}</strong>!</p>
<p>Ваше замовлення <strong>#${orderNumber}</strong> успішно оплачено.</p>
<div style="background:#f9fafb;border-radius:12px;padding:16px;margin:16px 0;">
${items.map((i) => `<div style="display:flex;justify-content:space-between;padding:4px 0;"><span>${i.categoryName} × ${i.quantity}</span><span>${i.price * i.quantity} грн</span></div>`).join('')}
<div style="border-top:1px solid #e5e7eb;margin-top:8px;padding-top:8px;font-weight:bold;display:flex;justify-content:space-between;">
<span>Разом</span><span>${totalAmount} грн</span>
</div>
</div>
<h2 style="font-size:18px;color:#223D17;">Ваші квитки:</h2>
<p style="font-size:14px;color:#6b7280;">Покажіть QR-код на вході або завантажте PDF з вкладення.</p>
${ticketsHtml}
<div style="background:#FFF9E6;border-radius:12px;padding:16px;margin-top:24px;">
<p style="margin:0;font-size:14px;color:#92400e;">
📍 <strong>Шуміленд</strong> сімейний тематичний парк<br>
📞 Телефон: ${process.env.SITE_PHONE || '+380 (67) 123-45-67'}<br>
🌐 <a href="https://shumiland.com.ua" style="color:#223D17;">shumiland.com.ua</a>
</p>
</div>
</body>
</html>`
await transporter.sendMail({
from: process.env.SMTP_FROM || '"Шуміленд" <tickets@shumiland.com.ua>',
to,
subject: `🎟 Ваші квитки Шуміленд — замовлення #${orderNumber}`,
html,
attachments: [
{
filename: `tickets-${orderNumber}.pdf`,
content: pdfBuffer,
contentType: 'application/pdf',
},
],
})
}

71
src/lib/ezy.ts Normal file
View file

@ -0,0 +1,71 @@
const EZY_ACTIVITY_KEY = process.env.EZY_ACTIVITY_KEY || ''
const EZY_PARTNER_KEY = process.env.EZY_PARTNER_KEY || ''
const EZY_BASE = 'https://www.ezy.com.ua'
export interface EzyTariff {
id: string | number
name: string
price: number
description?: string
activity_id?: string | number
}
export interface EzyOrderItem {
tariff_id: string | number
count: number
}
export interface EzyPaymentResult {
url: string
}
// Cache tariffs for 5 minutes
let tariffsCache: { data: EzyTariff[]; expiresAt: number } | null = null
export async function getTariffs(): Promise<EzyTariff[]> {
if (tariffsCache && Date.now() < tariffsCache.expiresAt) {
return tariffsCache.data
}
const res = await fetch(
`${EZY_BASE}/ipay/default/get-partner-tariff?activity=${EZY_ACTIVITY_KEY}`,
{ next: { revalidate: 300 } },
)
if (!res.ok) throw new Error(`ezy.com.ua tariffs: ${res.status}`)
const data = (await res.json()) as EzyTariff[]
tariffsCache = { data, expiresAt: Date.now() + 5 * 60_000 }
return data
}
export async function createPayment(params: {
email: string
orderReference: string
items: EzyOrderItem[]
}): Promise<EzyPaymentResult> {
const formData = new FormData()
formData.append('partner_key', EZY_PARTNER_KEY)
formData.append('email', params.email)
formData.append(
'order',
JSON.stringify({
reference: params.orderReference,
items: params.items,
}),
)
const res = await fetch(`${EZY_BASE}/ipay/pay/partner-pay`, {
method: 'POST',
body: formData,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`ezy.com.ua payment failed: ${res.status} ${text}`)
}
const json = await res.json()
if (!json?.url) throw new Error('ezy.com.ua: no payment URL in response')
return { url: json.url as string }
}

34
src/lib/qr.ts Normal file
View file

@ -0,0 +1,34 @@
import QRCode from 'qrcode'
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://shumiland.com.ua'
/**
* Generate a verify URL for a ticket QR code.
*/
export function getTicketVerifyUrl(ticketCode: string): string {
return `${SITE_URL}/api/tickets/verify/${ticketCode}`
}
/**
* Generate a QR code as a PNG base64 data URL.
*/
export async function generateQRDataUrl(text: string): Promise<string> {
return QRCode.toDataURL(text, {
width: 300,
margin: 2,
color: { dark: '#231F20', light: '#FFFFFF' },
errorCorrectionLevel: 'H',
})
}
/**
* Generate a QR code as SVG string.
*/
export async function generateQRSvg(text: string): Promise<string> {
return QRCode.toString(text, {
type: 'svg',
width: 200,
margin: 1,
errorCorrectionLevel: 'H',
})
}

File diff suppressed because one or more lines are too long