diff --git a/backend/src/index.js b/backend/src/index.js index b651d36..7465129 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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'); diff --git a/backend/src/routes/publicLottery.js b/backend/src/routes/publicLottery.js new file mode 100644 index 0000000..82143b8 --- /dev/null +++ b/backend/src/routes/publicLottery.js @@ -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; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 4366b84..c19eadd 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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([]); + const [selectedDrawId, setSelectedDrawId] = useState(''); + const [form, setForm] = useState(initialForm); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [loadError, setLoadError] = useState(''); + const [formError, setFormError] = useState(''); + const [confirmation, setConfirmation] = useState(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) => ( -
- -
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } + 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) => { + event.preventDefault(); + if (!selectedDraw) return; + + setSubmitting(true); + setFormError(''); + setConfirmation(null); + + try { + const response = await axios.post('/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 ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Sorteos activos')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+
+
+ + + L + + + LOTERÍA + Sorteos confiables + + + +
+ +
+
+ + Portal público de compra + reservas en tiempo real + +

+ Elige tus números y reserva tickets con monedero electrónico. +

+

+ 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. +

+
+ + Ver sorteos activos + + + Ir al panel admin + +
+
+ +
+
+
+
+

Sorteo destacado

+

{selectedDraw?.title || 'Próximamente'}

+
+ + Activo + +
+

+ {selectedDraw?.description || 'Cuando actives un sorteo desde el panel, aparecerá aquí automáticamente.'} +

+
+
+

{selectedDraw?.ticket_quantity || 0}

+

tickets

+
+
+

{selectedDraw?.availableCount || 0}

+

libres

+
+
+

{progress}%

+

vendido

+
+
+
+
+
+
+
- - - +
+
- - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+
+
+

Compra guiada

+

Sorteos disponibles

+

+ Selecciona un sorteo, revisa disponibilidad y completa la reserva. El pago queda pendiente + para validación manual del administrador. +

+
+ + Acceder al administrador → + +
-
+ {loading && ( +
+

Cargando sorteos activos...

+
+ )} + + {!loading && loadError && ( +
{loadError}
+ )} + + {!loading && !loadError && !draws.length && ( +
+

Todavía no hay sorteos activos.

+

Entra al panel admin, crea un sorteo y marca su estado como activo.

+ + Crear sorteo + +
+ )} + + {!!draws.length && selectedDraw && ( +
+
+ {draws.map((draw) => { + const isSelected = draw.id === selectedDraw.id; + return ( + + ); + })} +
+ +
+
+
+
+

Detalle del sorteo

+

{selectedDraw.title}

+
+
+

Total seleccionado

+

{currency.format(amount)}

+
+
+
+
+

{selectedDraw.soldCount}

+

vendidos/reservados

+
+
+

{selectedDraw.availableCount}

+

disponibles

+
+
+

{currency.format(selectedDraw.totalReservedRevenue)}

+

reservado

+
+
+

{currency.format(selectedDraw.totalExpectedRevenue)}

+

meta

+
+
+
+ +
+ +

Escribe números separados por coma o toca los accesos rápidos.

+ 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" + /> +
+ {suggestedTickets.map((ticket) => { + const isSold = soldTicketSet.has(ticket); + const isSelected = selectedTickets.includes(ticket); + return ( + + ); + })} +
+

Mostrando accesos rápidos del 1 al {suggestedTickets.length}. Puedes escribir cualquier ticket hasta {selectedDraw.ticket_quantity}.

+
+ +
+ + + + + + +
+ + {formError &&
{formError}
} + + {confirmation && ( +
+

Reserva creada: {confirmation.reference}

+

{confirmation.paymentMessage}

+
+ Tickets: {confirmation.ticketNumbers.join(', ')} + Total: {currency.format(confirmation.amount)} + Cliente: {confirmation.customerName} + Vence: {formatDate(confirmation.expiresAt)} +
+
+ )} + + +
+
+ )} +
+ + + ); } Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; -