This commit is contained in:
Flatlogic Bot 2026-07-28 06:10:46 +00:00
parent fe4bf2f0f0
commit d49d3f0be3
3 changed files with 697 additions and 143 deletions

View File

@ -16,6 +16,7 @@ const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
const publicLotteryRoutes = require('./routes/publicLottery');
const openaiRoutes = require('./routes/openai');
@ -90,6 +91,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.use('/api/public-lottery', publicLotteryRoutes);
app.enable('trust proxy');

View File

@ -0,0 +1,214 @@
const express = require('express');
const db = require('../db/models');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
const { Op } = db.Sequelize;
const LOCKING_STATUSES = ['reserved', 'paid'];
const MAX_TICKETS_PER_ORDER = 8;
function normalizeText(value) {
return String(value || '').trim();
}
function badRequest(message) {
const error = new Error(message);
error.code = 400;
return error;
}
function publicDrawPayload(draw, soldTickets) {
const quantity = Number(draw.ticket_quantity || 0);
const price = Number(draw.ticket_price || 0);
const soldCount = soldTickets.length;
return {
id: draw.id,
title: draw.title,
description: draw.description,
ticket_quantity: quantity,
ticket_price: price,
status: draw.status,
sales_start_at: draw.sales_start_at,
sales_end_at: draw.sales_end_at,
draw_at: draw.draw_at,
soldTickets,
soldCount,
availableCount: Math.max(quantity - soldCount, 0),
totalExpectedRevenue: Number(draw.total_expected_revenue || quantity * price || 0),
totalReservedRevenue: Number((soldCount * price).toFixed(2)),
};
}
async function soldTicketsForDraw(drawId, transaction) {
const rows = await db.ticket_reservations.findAll({
where: {
drawId,
is_active: true,
reservation_status: { [Op.in]: LOCKING_STATUSES },
},
attributes: ['ticket_number'],
transaction,
});
return rows
.map((row) => Number(row.ticket_number))
.filter((number) => Number.isInteger(number))
.sort((a, b) => a - b);
}
router.get('/draws', wrapAsync(async (req, res) => {
const draws = await db.draws.findAll({
where: { status: 'active' },
order: [['draw_at', 'ASC'], ['createdAt', 'DESC']],
limit: 12,
});
const payload = await Promise.all(
draws.map(async (draw) => publicDrawPayload(draw, await soldTicketsForDraw(draw.id))),
);
res.status(200).send({ rows: payload });
}));
router.get('/draws/:id', wrapAsync(async (req, res) => {
const draw = await db.draws.findOne({
where: {
id: req.params.id,
status: 'active',
},
});
if (!draw) {
throw badRequest('El sorteo no está disponible para compra.');
}
res.status(200).send(publicDrawPayload(draw, await soldTicketsForDraw(draw.id)));
}));
router.post('/reservations', wrapAsync(async (req, res) => {
const body = req.body || {};
const drawId = normalizeText(body.drawId);
const customerName = normalizeText(body.customerName);
const customerDocument = normalizeText(body.customerDocument);
const customerWhatsapp = normalizeText(body.customerWhatsapp);
const customerEmail = normalizeText(body.customerEmail).toLowerCase();
const walletProvider = normalizeText(body.walletProvider) || 'Monedero electrónico';
const walletReference = normalizeText(body.walletReference);
const ticketNumbers = Array.isArray(body.ticketNumbers)
? body.ticketNumbers.map((ticket) => Number(ticket))
: [];
if (!drawId) throw badRequest('Selecciona un sorteo.');
if (customerName.length < 3) throw badRequest('Ingresa el nombre completo del cliente.');
if (customerDocument.length < 4) throw badRequest('Ingresa un documento de identidad válido.');
if (customerWhatsapp.length < 7) throw badRequest('Ingresa un número de WhatsApp válido.');
if (!/^\S+@\S+\.\S+$/.test(customerEmail)) throw badRequest('Ingresa un email válido.');
if (!ticketNumbers.length) throw badRequest('Elige al menos un ticket.');
if (ticketNumbers.length > MAX_TICKETS_PER_ORDER) {
throw badRequest(`Puedes reservar máximo ${MAX_TICKETS_PER_ORDER} tickets por solicitud.`);
}
const uniqueTickets = [...new Set(ticketNumbers)];
if (uniqueTickets.length !== ticketNumbers.length) throw badRequest('Hay números de ticket repetidos.');
if (!uniqueTickets.every((ticket) => Number.isInteger(ticket) && ticket > 0)) {
throw badRequest('Todos los tickets deben ser números positivos.');
}
const result = await db.sequelize.transaction(async (transaction) => {
const draw = await db.draws.findOne({
where: { id: drawId, status: 'active' },
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!draw) throw badRequest('El sorteo no está activo o no existe.');
const quantity = Number(draw.ticket_quantity || 0);
const price = Number(draw.ticket_price || 0);
if (!quantity || !price) throw badRequest('El sorteo no tiene tickets o precio configurado.');
if (!uniqueTickets.every((ticket) => ticket <= quantity)) {
throw badRequest(`Elige tickets entre 1 y ${quantity}.`);
}
const existing = await db.ticket_reservations.findAll({
where: {
drawId,
ticket_number: { [Op.in]: uniqueTickets },
is_active: true,
reservation_status: { [Op.in]: LOCKING_STATUSES },
},
attributes: ['ticket_number'],
transaction,
lock: transaction.LOCK.UPDATE,
});
if (existing.length) {
const taken = existing.map((item) => item.ticket_number).sort((a, b) => a - b).join(', ');
throw badRequest(`Estos tickets ya no están disponibles: ${taken}.`);
}
const now = new Date();
const reference = `LOT-${now.getTime().toString(36).toUpperCase()}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
const amount = Number((price * uniqueTickets.length).toFixed(2));
const reservations = [];
for (const ticketNumber of uniqueTickets.sort((a, b) => a - b)) {
const reservation = await db.ticket_reservations.create({
drawId,
ticket_number: ticketNumber,
reservation_status: 'reserved',
payment_status: 'pending',
reserved_at: now,
expires_at: new Date(now.getTime() + 60 * 60 * 1000),
amount: price,
public_reference: reference,
is_active: true,
notes: [
'Reserva pública creada desde el portal. Pendiente de confirmación de pago por monedero.',
`Cliente: ${customerName}`,
`Documento: ${customerDocument}`,
`WhatsApp: ${customerWhatsapp}`,
`Email: ${customerEmail}`,
`Monedero: ${walletProvider}`,
`Referencia pago: ${walletReference || 'No informada'}`,
].join('\n'),
}, { transaction });
await db.payments.create({
reservationId: reservation.id,
provider: 'wallet',
status: 'pending',
amount: price,
currency: 'COP',
provider_reference: walletReference || reference,
initiated_at: now,
status_message: `Pago pendiente por ${walletProvider}.`,
}, { transaction });
reservations.push(reservation);
}
return {
reference,
draw: {
id: draw.id,
title: draw.title,
},
ticketNumbers: uniqueTickets,
amount,
customerName,
customerWhatsapp,
customerEmail,
expiresAt: reservations[0].expires_at,
paymentMessage: 'Reserva registrada. El administrador debe confirmar el pago recibido en el monedero.',
};
});
res.status(201).send(result);
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -1,166 +1,504 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import React, { FormEvent, ReactElement, useEffect, useMemo, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import axios from 'axios';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
type PublicDraw = {
id: string;
title: string;
description?: string;
ticket_quantity: number;
ticket_price: number;
status: string;
draw_at?: string;
soldTickets: number[];
soldCount: number;
availableCount: number;
totalExpectedRevenue: number;
totalReservedRevenue: number;
};
type ReservationConfirmation = {
reference: string;
draw: { id: string; title: string };
ticketNumbers: number[];
amount: number;
customerName: string;
customerWhatsapp: string;
customerEmail: string;
expiresAt: string;
paymentMessage: string;
};
type BuyerForm = {
customerName: string;
customerDocument: string;
customerWhatsapp: string;
customerEmail: string;
walletProvider: string;
walletReference: string;
ticketInput: string;
};
const initialForm: BuyerForm = {
customerName: '',
customerDocument: '',
customerWhatsapp: '',
customerEmail: '',
walletProvider: 'Nequi / Monedero electrónico',
walletReference: '',
ticketInput: '',
};
const currency = new Intl.NumberFormat('es-CO', {
style: 'currency',
currency: 'COP',
maximumFractionDigits: 0,
});
function formatDate(value?: string) {
if (!value) return 'Fecha por confirmar';
return new Intl.DateTimeFormat('es', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function parseTicketInput(value: string) {
return value
.split(/[\s,;]+/)
.map((part) => Number(part.trim()))
.filter((number) => Number.isInteger(number) && number > 0);
}
function errorMessage(error: unknown) {
if (axios.isAxiosError(error)) {
const data = error.response?.data;
return typeof data === 'string' ? data : 'No pudimos completar la reserva. Intenta nuevamente.';
}
return 'Ocurrió un error inesperado. Intenta nuevamente.';
}
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('background');
const textColor = useAppSelector((state) => state.style.linkColor);
const [draws, setDraws] = useState<PublicDraw[]>([]);
const [selectedDrawId, setSelectedDrawId] = useState<string>('');
const [form, setForm] = useState<BuyerForm>(initialForm);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [loadError, setLoadError] = useState('');
const [formError, setFormError] = useState('');
const [confirmation, setConfirmation] = useState<ReservationConfirmation | null>(null);
const title = 'Loteria Sorteos Web'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
useEffect(() => {
const fetchDraws = async () => {
try {
setLoading(true);
setLoadError('');
const response = await axios.get<{ rows: PublicDraw[] }>('/public-lottery/draws');
const activeDraws = response.data.rows || [];
setDraws(activeDraws);
setSelectedDrawId((current) => current || activeDraws[0]?.id || '');
} catch (error) {
console.error('Failed to load public lottery draws:', error);
setLoadError('No pudimos cargar los sorteos activos. Revisa la conexión e intenta otra vez.');
} finally {
setLoading(false);
}
};
fetchDraws();
}, []);
const selectedDraw = useMemo(
() => draws.find((draw) => draw.id === selectedDrawId) || draws[0],
[draws, selectedDrawId],
);
const selectedTickets = useMemo(() => parseTicketInput(form.ticketInput), [form.ticketInput]);
const soldTicketSet = useMemo(
() => new Set(selectedDraw?.soldTickets || []),
[selectedDraw?.soldTickets],
);
const amount = Number(((selectedDraw?.ticket_price || 0) * selectedTickets.length).toFixed(2));
const progress = selectedDraw?.ticket_quantity
? Math.round((selectedDraw.soldCount / selectedDraw.ticket_quantity) * 100)
: 0;
const suggestedTickets = useMemo(() => {
if (!selectedDraw) return [];
const tickets: number[] = [];
for (let number = 1; number <= selectedDraw.ticket_quantity && tickets.length < 60; number += 1) {
tickets.push(number);
}
return tickets;
}, [selectedDraw]);
const updateForm = (field: keyof BuyerForm, value: string) => {
setForm((current) => ({ ...current, [field]: value }));
setFormError('');
};
const toggleTicket = (ticket: number) => {
if (soldTicketSet.has(ticket)) return;
const tickets = new Set(selectedTickets);
if (tickets.has(ticket)) {
tickets.delete(ticket);
} else {
tickets.add(ticket);
}
updateForm('ticketInput', [...tickets].sort((a, b) => a - b).join(', '));
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedDraw) return;
setSubmitting(true);
setFormError('');
setConfirmation(null);
try {
const response = await axios.post<ReservationConfirmation>('/public-lottery/reservations', {
drawId: selectedDraw.id,
customerName: form.customerName,
customerDocument: form.customerDocument,
customerWhatsapp: form.customerWhatsapp,
customerEmail: form.customerEmail,
walletProvider: form.walletProvider,
walletReference: form.walletReference,
ticketNumbers: selectedTickets,
});
setConfirmation(response.data);
setForm({ ...initialForm, walletProvider: form.walletProvider });
const refreshed = await axios.get<{ rows: PublicDraw[] }>('/public-lottery/draws');
setDraws(refreshed.data.rows || []);
} catch (error) {
console.error('Failed to create public lottery reservation:', error);
setFormError(errorMessage(error));
} finally {
setSubmitting(false);
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Sorteos activos')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Loteria Sorteos Web app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<main className="min-h-screen bg-[#07111f] text-white">
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(45,212,191,0.28),_transparent_34%),radial-gradient(circle_at_top_right,_rgba(249,115,22,0.2),_transparent_30%),linear-gradient(135deg,_#07111f_0%,_#101827_52%,_#172554_100%)]" />
<div className="relative mx-auto flex max-w-7xl flex-col gap-12 px-5 py-6 sm:px-8 lg:px-10">
<header className="flex items-center justify-between rounded-full border border-white/10 bg-white/10 px-5 py-3 shadow-2xl shadow-black/20 backdrop-blur">
<Link href="/" className="flex items-center gap-3" aria-label="Ir al inicio">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#2dd4bf] font-black text-[#07111f] shadow-lg shadow-teal-400/30">
L
</span>
<span>
<span className="block text-sm font-semibold tracking-[0.28em] text-teal-100">LOTERÍA</span>
<span className="block text-xs text-slate-300">Sorteos confiables</span>
</span>
</Link>
<nav className="flex items-center gap-3 text-sm">
<a href="#comprar" className="hidden rounded-full px-4 py-2 text-slate-200 transition hover:bg-white/10 sm:inline-flex">
Comprar tickets
</a>
<Link
href="/login"
className="rounded-full bg-white px-5 py-2 font-semibold text-[#07111f] shadow-lg shadow-white/10 transition hover:-translate-y-0.5 hover:bg-teal-100 focus:outline-none focus:ring-2 focus:ring-[#2dd4bf]"
>
Admin login
</Link>
</nav>
</header>
<div className="grid items-center gap-10 py-10 lg:grid-cols-[1.05fr_0.95fr] lg:py-16">
<div className="max-w-3xl">
<span className="inline-flex rounded-full border border-teal-300/30 bg-teal-300/10 px-4 py-2 text-sm font-semibold text-teal-100">
Portal público de compra + reservas en tiempo real
</span>
<h1 className="mt-6 text-5xl font-black leading-tight tracking-tight text-white sm:text-6xl lg:text-7xl">
Elige tus números y reserva tickets con monedero electrónico.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
Un flujo rápido para vender tickets de sorteos: el cliente selecciona números disponibles,
deja sus datos y recibe una referencia para confirmar el pago.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<a
href="#comprar"
className="inline-flex items-center justify-center rounded-2xl bg-[#f97316] px-7 py-4 font-bold text-white shadow-xl shadow-orange-500/20 transition hover:-translate-y-0.5 hover:bg-[#fb923c] focus:outline-none focus:ring-2 focus:ring-orange-200"
>
Ver sorteos activos
</a>
<Link
href="/draws/draws-list"
className="inline-flex items-center justify-center rounded-2xl border border-white/15 bg-white/10 px-7 py-4 font-bold text-white transition hover:-translate-y-0.5 hover:bg-white/15 focus:outline-none focus:ring-2 focus:ring-teal-200"
>
Ir al panel admin
</Link>
</div>
</div>
<div className="rounded-[2rem] border border-white/10 bg-white/10 p-4 shadow-2xl shadow-black/30 backdrop-blur-xl">
<div className="rounded-[1.5rem] bg-[#0f172a] p-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-teal-200">Sorteo destacado</p>
<h2 className="mt-3 text-3xl font-black">{selectedDraw?.title || 'Próximamente'}</h2>
</div>
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-sm font-bold text-emerald-200">
Activo
</span>
</div>
<p className="mt-4 min-h-[4rem] text-slate-300">
{selectedDraw?.description || 'Cuando actives un sorteo desde el panel, aparecerá aquí automáticamente.'}
</p>
<div className="mt-6 grid grid-cols-3 gap-3 text-center">
<div className="rounded-2xl bg-white/5 p-4">
<p className="text-2xl font-black text-white">{selectedDraw?.ticket_quantity || 0}</p>
<p className="text-xs text-slate-400">tickets</p>
</div>
<div className="rounded-2xl bg-white/5 p-4">
<p className="text-2xl font-black text-[#2dd4bf]">{selectedDraw?.availableCount || 0}</p>
<p className="text-xs text-slate-400">libres</p>
</div>
<div className="rounded-2xl bg-white/5 p-4">
<p className="text-2xl font-black text-[#f97316]">{progress}%</p>
<p className="text-xs text-slate-400">vendido</p>
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-gradient-to-r from-[#2dd4bf] to-[#f97316]" style={{ width: `${Math.min(progress, 100)}%` }} />
</div>
</div>
</div>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</div>
</section>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<section id="comprar" className="bg-slate-50 px-5 py-16 text-slate-900 sm:px-8 lg:px-10">
<div className="mx-auto max-w-7xl">
<div className="mb-8 flex flex-col justify-between gap-4 lg:flex-row lg:items-end">
<div>
<p className="text-sm font-bold uppercase tracking-[0.35em] text-[#0f766e]">Compra guiada</p>
<h2 className="mt-2 text-4xl font-black tracking-tight text-slate-950">Sorteos disponibles</h2>
<p className="mt-3 max-w-2xl text-slate-600">
Selecciona un sorteo, revisa disponibilidad y completa la reserva. El pago queda pendiente
para validación manual del administrador.
</p>
</div>
<Link href="/login" className="text-sm font-bold text-[#0f766e] underline-offset-4 hover:underline">
Acceder al administrador
</Link>
</div>
</div>
{loading && (
<div className="rounded-3xl border border-slate-200 bg-white p-10 text-center shadow-sm">
<p className="font-semibold text-slate-700">Cargando sorteos activos...</p>
</div>
)}
{!loading && loadError && (
<div className="rounded-3xl border border-red-200 bg-red-50 p-6 text-red-700">{loadError}</div>
)}
{!loading && !loadError && !draws.length && (
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-sm">
<p className="text-2xl font-black text-slate-950">Todavía no hay sorteos activos.</p>
<p className="mt-2 text-slate-600">Entra al panel admin, crea un sorteo y marca su estado como activo.</p>
<Link
href="/draws/draws-new"
className="mt-6 inline-flex rounded-2xl bg-[#0f766e] px-6 py-3 font-bold text-white transition hover:bg-[#115e59]"
>
Crear sorteo
</Link>
</div>
)}
{!!draws.length && selectedDraw && (
<div className="grid gap-8 lg:grid-cols-[0.9fr_1.1fr]">
<div className="space-y-4">
{draws.map((draw) => {
const isSelected = draw.id === selectedDraw.id;
return (
<button
key={draw.id}
type="button"
onClick={() => {
setSelectedDrawId(draw.id);
setFormError('');
setConfirmation(null);
}}
className={`w-full rounded-3xl border p-5 text-left shadow-sm transition focus:outline-none focus:ring-2 focus:ring-[#2dd4bf] ${
isSelected
? 'border-[#2dd4bf] bg-[#ecfeff] shadow-teal-100'
: 'border-slate-200 bg-white hover:-translate-y-0.5 hover:border-slate-300'
}`}
>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-black text-slate-950">{draw.title}</h3>
<p className="mt-2 line-clamp-2 text-sm text-slate-600">{draw.description}</p>
</div>
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold text-emerald-700">Activo</span>
</div>
<div className="mt-5 grid grid-cols-3 gap-3 text-sm">
<div className="rounded-2xl bg-slate-100 p-3">
<p className="font-black text-slate-950">{currency.format(draw.ticket_price)}</p>
<p className="text-slate-500">por ticket</p>
</div>
<div className="rounded-2xl bg-slate-100 p-3">
<p className="font-black text-slate-950">{draw.availableCount}</p>
<p className="text-slate-500">libres</p>
</div>
<div className="rounded-2xl bg-slate-100 p-3">
<p className="font-black text-slate-950">{formatDate(draw.draw_at)}</p>
<p className="text-slate-500">fecha</p>
</div>
</div>
</button>
);
})}
</div>
<form onSubmit={handleSubmit} className="rounded-[2rem] border border-slate-200 bg-white p-5 shadow-xl shadow-slate-200/70 sm:p-7">
<div className="rounded-3xl bg-slate-950 p-5 text-white">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
<div>
<p className="text-sm font-bold uppercase tracking-[0.28em] text-teal-200">Detalle del sorteo</p>
<h3 className="mt-2 text-3xl font-black">{selectedDraw.title}</h3>
</div>
<div className="rounded-2xl bg-white/10 px-4 py-3 text-right">
<p className="text-xs text-slate-300">Total seleccionado</p>
<p className="text-2xl font-black text-[#2dd4bf]">{currency.format(amount)}</p>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-4">
<div className="rounded-2xl bg-white/5 p-4">
<p className="text-2xl font-black">{selectedDraw.soldCount}</p>
<p className="text-xs text-slate-400">vendidos/reservados</p>
</div>
<div className="rounded-2xl bg-white/5 p-4">
<p className="text-2xl font-black">{selectedDraw.availableCount}</p>
<p className="text-xs text-slate-400">disponibles</p>
</div>
<div className="rounded-2xl bg-white/5 p-4">
<p className="text-2xl font-black">{currency.format(selectedDraw.totalReservedRevenue)}</p>
<p className="text-xs text-slate-400">reservado</p>
</div>
<div className="rounded-2xl bg-white/5 p-4">
<p className="text-2xl font-black">{currency.format(selectedDraw.totalExpectedRevenue)}</p>
<p className="text-xs text-slate-400">meta</p>
</div>
</div>
</div>
<div className="mt-6">
<label htmlFor="ticketInput" className="text-sm font-bold text-slate-800">
Números de tickets
</label>
<p className="mt-1 text-sm text-slate-500">Escribe números separados por coma o toca los accesos rápidos.</p>
<input
id="ticketInput"
value={form.ticketInput}
onChange={(event) => updateForm('ticketInput', event.target.value)}
placeholder="Ej: 7, 21, 105"
className="mt-2 w-full rounded-2xl border border-slate-300 px-4 py-3 text-slate-900 outline-none transition focus:border-[#0f766e] focus:ring-4 focus:ring-teal-100"
/>
<div className="mt-3 grid max-h-48 grid-cols-8 gap-2 overflow-y-auto rounded-2xl bg-slate-50 p-3 sm:grid-cols-10">
{suggestedTickets.map((ticket) => {
const isSold = soldTicketSet.has(ticket);
const isSelected = selectedTickets.includes(ticket);
return (
<button
key={ticket}
type="button"
disabled={isSold}
onClick={() => toggleTicket(ticket)}
className={`rounded-xl px-2 py-2 text-sm font-bold transition focus:outline-none focus:ring-2 focus:ring-[#2dd4bf] ${
isSold
? 'cursor-not-allowed bg-slate-200 text-slate-400 line-through'
: isSelected
? 'bg-[#f97316] text-white shadow-md shadow-orange-200'
: 'bg-white text-slate-700 shadow-sm hover:bg-teal-50'
}`}
>
{ticket}
</button>
);
})}
</div>
<p className="mt-2 text-xs text-slate-500">Mostrando accesos rápidos del 1 al {suggestedTickets.length}. Puedes escribir cualquier ticket hasta {selectedDraw.ticket_quantity}.</p>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="text-sm font-bold text-slate-800">Nombre completo</span>
<input value={form.customerName} onChange={(event) => updateForm('customerName', event.target.value)} className="mt-2 w-full rounded-2xl border border-slate-300 px-4 py-3 outline-none focus:border-[#0f766e] focus:ring-4 focus:ring-teal-100" required />
</label>
<label className="block">
<span className="text-sm font-bold text-slate-800">Documento de identidad</span>
<input value={form.customerDocument} onChange={(event) => updateForm('customerDocument', event.target.value)} className="mt-2 w-full rounded-2xl border border-slate-300 px-4 py-3 outline-none focus:border-[#0f766e] focus:ring-4 focus:ring-teal-100" required />
</label>
<label className="block">
<span className="text-sm font-bold text-slate-800">WhatsApp</span>
<input value={form.customerWhatsapp} onChange={(event) => updateForm('customerWhatsapp', event.target.value)} className="mt-2 w-full rounded-2xl border border-slate-300 px-4 py-3 outline-none focus:border-[#0f766e] focus:ring-4 focus:ring-teal-100" required />
</label>
<label className="block">
<span className="text-sm font-bold text-slate-800">Email</span>
<input type="email" value={form.customerEmail} onChange={(event) => updateForm('customerEmail', event.target.value)} className="mt-2 w-full rounded-2xl border border-slate-300 px-4 py-3 outline-none focus:border-[#0f766e] focus:ring-4 focus:ring-teal-100" required />
</label>
<label className="block">
<span className="text-sm font-bold text-slate-800">Monedero electrónico</span>
<input value={form.walletProvider} onChange={(event) => updateForm('walletProvider', event.target.value)} className="mt-2 w-full rounded-2xl border border-slate-300 px-4 py-3 outline-none focus:border-[#0f766e] focus:ring-4 focus:ring-teal-100" required />
</label>
<label className="block">
<span className="text-sm font-bold text-slate-800">Referencia de pago (opcional)</span>
<input value={form.walletReference} onChange={(event) => updateForm('walletReference', event.target.value)} placeholder="ID de transacción" className="mt-2 w-full rounded-2xl border border-slate-300 px-4 py-3 outline-none focus:border-[#0f766e] focus:ring-4 focus:ring-teal-100" />
</label>
</div>
{formError && <div className="mt-5 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm font-semibold text-red-700">{formError}</div>}
{confirmation && (
<div className="mt-5 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 text-emerald-900">
<p className="text-lg font-black">Reserva creada: {confirmation.reference}</p>
<p className="mt-2 text-sm">{confirmation.paymentMessage}</p>
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
<span>Tickets: <strong>{confirmation.ticketNumbers.join(', ')}</strong></span>
<span>Total: <strong>{currency.format(confirmation.amount)}</strong></span>
<span>Cliente: <strong>{confirmation.customerName}</strong></span>
<span>Vence: <strong>{formatDate(confirmation.expiresAt)}</strong></span>
</div>
</div>
)}
<button
type="submit"
disabled={submitting}
className="mt-6 w-full rounded-2xl bg-[#0f766e] px-6 py-4 text-lg font-black text-white shadow-xl shadow-teal-700/20 transition hover:-translate-y-0.5 hover:bg-[#115e59] focus:outline-none focus:ring-4 focus:ring-teal-200 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? 'Creando reserva...' : 'Reservar tickets'}
</button>
</form>
</div>
)}
</div>
</section>
</main>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};