0.1
This commit is contained in:
parent
fe4bf2f0f0
commit
d49d3f0be3
@ -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');
|
||||
|
||||
|
||||
|
||||
214
backend/src/routes/publicLottery.js
Normal file
214
backend/src/routes/publicLottery.js
Normal 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;
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user