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:
parent
67140a9ec4
commit
eafa994484
11 changed files with 751 additions and 3 deletions
272
src/app/(frontend)/payments/page.tsx
Normal file
272
src/app/(frontend)/payments/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
src/app/(frontend)/thank-you/page.tsx
Normal file
41
src/app/(frontend)/thank-you/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/app/api/tickets/create/route.ts
Normal file
91
src/app/api/tickets/create/route.ts
Normal 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 })
|
||||
}
|
||||
59
src/app/api/tickets/verify/[code]/route.ts
Normal file
59
src/app/api/tickets/verify/[code]/route.ts
Normal 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: '✅ Квиток дійсний — прохід дозволено',
|
||||
})
|
||||
}
|
||||
98
src/app/api/tickets/webhook/route.ts
Normal file
98
src/app/api/tickets/webhook/route.ts
Normal 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 })
|
||||
}
|
||||
|
|
@ -43,7 +43,6 @@ export const Orders: CollectionConfig = {
|
|||
name: 'orderNumber',
|
||||
type: 'text',
|
||||
label: 'Номер замовлення',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
|
|
|
|||
|
|
@ -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
84
src/lib/email.ts
Normal 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
71
src/lib/ezy.ts
Normal 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
34
src/lib/qr.ts
Normal 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
Loading…
Add table
Reference in a new issue