diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..01bbbbc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: + - "**" + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Check scripts syntax + run: node --check scripts.js + + - name: Run raffle core tests + run: node tests/raffle-core.test.js diff --git a/README.md b/README.md index 13dfabc..8568166 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,52 @@ -## **Sorteo Simple:** +# Sorteo Simple -**Demo:** [https://emanuelhg.github.io/SorteoSimple/](https://emanuelhg.github.io/SorteoSimple/) +Demo: [https://emanuelhg.github.io/SorteoSimple/](https://emanuelhg.github.io/SorteoSimple/) -Este sitio es una página web que permite realizar un sorteo de forma simple, donde se puede ingresar la cantidad de premios a sortear y la lista de participantes. El sitio utiliza **HTML**, **CSS** y **JavaScript** para crear la interfaz de usuario y llevar a cabo la lógica de sorteo. +Sorteo Simple es una aplicación web estática para elegir ganadores de forma aleatoria a partir de una lista de participantes. No necesita backend ni build: todo funciona con HTML, CSS y JavaScript vanilla. -La página se compone de una sección de formulario, que incluye una entrada de texto para la cantidad de premios, una entrada de texto grande para la lista de participantes y dos botones para ejecutar el sorteo y limpiar los datos. También hay una sección de resultado, que muestra los ganadores del sorteo. Además, hay un pie de página que incluye información sobre el creador del sitio y enlaces a sus perfiles de LinkedIn y GitHub. +## Funciones principales -El sitio utiliza la biblioteca Bootstrap para dar formato a la página y la biblioteca SweetAlert para mostrar alertas de copiado de texto. La lógica de sorteo se realiza en el archivo "scripts.js", donde se definen varias funciones que se encargan de validar los datos ingresados y realizar el sorteo. +- Define entre 1 y 100 premios. +- Carga participantes uno por línea. +- Detecta repetidos y permite limpiarlos en un clic. +- Puede quitar duplicados automáticamente al sortear. +- Permite volver a sortear manteniendo la misma base de participantes. +- Puede excluir ganadores previos para rondas sucesivas. +- Guarda el estado en `localStorage`. +- Copia el resultado al portapapeles. +- Exporta el resultado a un archivo `.txt`. +- Muestra una revelación animada de ganadores. +- Usa confirmaciones suaves para acciones sensibles. -En general, el sitio es fácil de usar y ofrece una forma sencilla de realizar un sorteo en línea. +## Estructura + +- `index.html`: interfaz principal. +- `styles.css`: sistema visual responsive. +- `raffle-core.js`: lógica pura del sorteo y validaciones reutilizables. +- `scripts.js`: integración con DOM, persistencia y acciones de la UI. +- `favicon.svg`: marca visual y favicon del sitio. +- `site.webmanifest`: metadatos básicos de identidad del sitio. +- `tests/raffle-core.test.js`: pruebas básicas del módulo puro. +- `media/`: iconos del footer. + +## Cómo usarlo + +1. Abre `index.html` en tu navegador. +2. Escribe participantes, uno por línea. +3. Ajusta la cantidad de premios y, si quieres, las opciones avanzadas. +4. Ejecuta el sorteo, vuelve a sortear, copia o exporta el resultado. + +## Verificación rápida + +- Sintaxis JS: `node --check scripts.js` +- Tests del módulo puro: `node tests/raffle-core.test.js` + +## Mejoras aplicadas + +- Rediseño integral de la interfaz. +- Validaciones inline y feedback con toast. +- Estado vacío para resultados. +- Métricas de participantes repetidos y disponibles. +- Historial acumulado de ganadores excluidos. +- Separación entre lógica pura y código de interfaz. +- Favicon y branding propio. diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..13c877f --- /dev/null +++ b/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/index.html b/index.html index bc87859..95d4bac 100644 --- a/index.html +++ b/index.html @@ -1,76 +1,179 @@ - + - - - + + Sorteo Simple + + + + + + -

Sorteo Simple

-

- Realiza un sorteo según la cantidad de premios y participantes -

-
-
-
-
- - +
+
+
+

Sorteos sin vueltas

+
+ +

Sorteo Simple

-
-
-
-
-
- - +

+ Cargá participantes, definí premios y obtené ganadores al instante con una herramienta simple y clara. +

+
+ +
+
+
+

Configurar sorteo

+

Prepará la lista, ajustá opciones y dejá todo listo para sortear.

+
+ + +
+
+ + + Permitido: entre 1 y 100 premios. +

+
+
+ +
+
+ + 0 participantes +
+ + Las líneas vacías se ignoran. Podés limpiar nombres repetidos en un clic. +

+
+
+ +
-
- - + +
+ Opciones avanzadas +
+ + +
+
+ +
+ + +
-
-
-
-
- - +
+ +
+
+

Resultado

+

Visualizá los ganadores, copiá el texto o exportalo para compartirlo.

+
+ +
+
+
+
+ Premios + 1 +
+
+ Participantes válidos + 0 +
+
+ +

+ 0 repetidos +

-
- +
+ +
+
+ + Sin sorteo
- -
+
+
+
+ Todo listo para empezar +

Cuando ejecutes el sorteo, vas a ver aquí los ganadores numerados y vas a poder copiarlos o exportarlos.

+
+ +
+ El resultado se genera automáticamente y queda bloqueado para evitar cambios manuales. +
+ +
+ Ganadores acumulados excluidos +

Todavía no se excluyeron ganadores previos.

+
+ +
+ + + +
+
+
+ + + + + +
+ + + diff --git a/raffle-core.js b/raffle-core.js new file mode 100644 index 0000000..864a490 --- /dev/null +++ b/raffle-core.js @@ -0,0 +1,121 @@ +(function (globalScope) { + const MIN_PRIZES = 1; + const MAX_PRIZES = 100; + + function sanitizePrizeCount(rawValue) { + const digitsOnly = String(rawValue).replace(/[^\d]/g, ""); + + if (digitsOnly === "") { + return MIN_PRIZES; + } + + const parsedValue = Number.parseInt(digitsOnly, 10); + return Math.min(MAX_PRIZES, Math.max(MIN_PRIZES, parsedValue)); + } + + function parseParticipants(rawText) { + return String(rawText) + .split("\n") + .map((entry) => entry.trim()) + .filter(Boolean); + } + + function getDuplicateEntries(participants) { + const counts = new Map(); + + participants.forEach((participant) => { + counts.set(participant, (counts.get(participant) || 0) + 1); + }); + + return Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([name, count]) => ({ name, count })) + .sort((left, right) => left.name.localeCompare(right.name)); + } + + function dedupeParticipants(participants) { + return Array.from(new Set(participants)); + } + + function buildEligibleParticipants(participants, previousWinners, excludePreviousWinners) { + if (!excludePreviousWinners) { + return [...participants]; + } + + const excluded = new Set(previousWinners); + return participants.filter((participant) => !excluded.has(participant)); + } + + function validateRaffle(options) { + const { + participants, + prizeCount, + previousWinners = [], + excludePreviousWinners = false, + } = options; + const uniqueParticipants = dedupeParticipants(participants); + + if (participants.length === 0) { + return { valid: false, message: "Ingresa al menos un participante para realizar el sorteo." }; + } + + if (prizeCount < MIN_PRIZES || prizeCount > MAX_PRIZES) { + return { valid: false, message: "La cantidad de premios debe estar entre 1 y 100." }; + } + + const eligibleParticipants = buildEligibleParticipants( + uniqueParticipants, + previousWinners, + excludePreviousWinners, + ); + + if (eligibleParticipants.length === 0) { + return { valid: false, message: "No quedan participantes disponibles con las opciones actuales." }; + } + + if (prizeCount > eligibleParticipants.length) { + return { + valid: false, + message: "La cantidad de premios no puede superar a los participantes disponibles.", + }; + } + + return { valid: true, message: "" }; + } + + function pickWinners(participants, prizeCount, excludedParticipants) { + const excluded = new Set(excludedParticipants || []); + const available = dedupeParticipants(participants).filter((participant) => !excluded.has(participant)); + const shuffled = [...available]; + + for (let index = shuffled.length - 1; index > 0; index -= 1) { + const randomIndex = Math.floor(Math.random() * (index + 1)); + [shuffled[index], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[index]]; + } + + return shuffled.slice(0, prizeCount); + } + + function formatWinners(winners) { + return winners.map((winner, index) => `Premio ${index + 1}: ${winner}`).join("\n"); + } + + const api = { + MIN_PRIZES, + MAX_PRIZES, + sanitizePrizeCount, + parseParticipants, + getDuplicateEntries, + dedupeParticipants, + buildEligibleParticipants, + validateRaffle, + pickWinners, + formatWinners, + }; + + globalScope.RaffleCore = api; + + if (typeof module !== "undefined" && module.exports) { + module.exports = api; + } +}(typeof window !== "undefined" ? window : globalThis)); diff --git a/scripts.js b/scripts.js index cacae51..f1f5bb5 100644 --- a/scripts.js +++ b/scripts.js @@ -1,79 +1,498 @@ -// variables -const cantPremios = document.getElementById("cantPremios"); -const origen = document.getElementById('formControlTextArea1'); -const destino = document.getElementById('formControlTextArea2') +const STORAGE_KEY = "sorteo-simple-state"; +const TOAST_DURATION_MS = 2800; +const DRAW_ANIMATION_MS = 620; -// funciones generales +const { + MIN_PRIZES, + sanitizePrizeCount, + parseParticipants, + getDuplicateEntries, + dedupeParticipants, + buildEligibleParticipants, + validateRaffle, + pickWinners, + formatWinners, +} = window.RaffleCore; -function refuerzaMinyMaX(cant) { +const raffleForm = document.getElementById("raffleForm"); +const prizesInput = document.getElementById("cantPremios"); +const participantsInput = document.getElementById("formControlTextArea1"); +const resultOutput = document.getElementById("formControlTextArea2"); +const copyButton = document.getElementById("copyButton"); +const exportButton = document.getElementById("exportButton"); +const resetButton = document.getElementById("resetButton"); +const rerollButton = document.getElementById("rerollButton"); +const clearHistoryButton = document.getElementById("clearHistoryButton"); +const removeDuplicatesButton = document.getElementById("removeDuplicatesButton"); +const excludePreviousWinnersInput = document.getElementById("excludePreviousWinners"); +const autoRemoveDuplicatesInput = document.getElementById("autoRemoveDuplicates"); +const participantsCounter = document.getElementById("participantsCounter"); +const participantsMetrics = document.getElementById("participantsMetrics"); +const summaryPrizes = document.getElementById("summaryPrizes"); +const summaryParticipants = document.getElementById("summaryParticipants"); +const summaryDuplicateParticipants = document.getElementById("summaryDuplicateParticipants"); +const resultBadge = document.getElementById("resultBadge"); +const winnerReveal = document.getElementById("winnerReveal"); +const emptyState = document.getElementById("emptyState"); +const historyText = document.getElementById("historyText"); +const prizeError = document.getElementById("prizeError"); +const participantsError = document.getElementById("participantsError"); +const toast = document.getElementById("toast"); +const panelTops = document.querySelectorAll(".panel-top"); +const confirmDialog = document.getElementById("confirmDialog"); +const confirmMessage = document.getElementById("confirmMessage"); +const confirmCancelButton = document.getElementById("confirmCancelButton"); +const confirmAcceptButton = document.getElementById("confirmAcceptButton"); - if (parseInt(cant.value) < 1) { - cant.value = 1; - } else if (parseInt(cant.value) > 100) { - cant.value = 100; - } else { - cant.value = withoutLetters(cant.value) +let toastTimeoutId; +let drawAnimationTimeoutId; +let pendingConfirmationResolve; + +const state = { + previousWinners: [], + lastUsedParticipants: [], +}; + +function loadState() { + try { + const saved = window.localStorage.getItem(STORAGE_KEY); + + if (!saved) { + return; + } + + const parsed = JSON.parse(saved); + prizesInput.value = String(sanitizePrizeCount(parsed.prizeCount ?? MIN_PRIZES)); + participantsInput.value = parsed.participantsText ?? ""; + resultOutput.value = parsed.resultText ?? ""; + excludePreviousWinnersInput.checked = Boolean(parsed.excludePreviousWinners); + autoRemoveDuplicatesInput.checked = Boolean(parsed.autoRemoveDuplicates); + state.previousWinners = Array.isArray(parsed.previousWinners) ? parsed.previousWinners : []; + state.lastUsedParticipants = Array.isArray(parsed.lastUsedParticipants) ? parsed.lastUsedParticipants : []; + } catch (error) { + console.error(error); } } -function withoutLetters(cant) { - return cant.replace(/[^0-9]/g, ''); +function persistState() { + const snapshot = { + prizeCount: sanitizePrizeCount(prizesInput.value), + participantsText: participantsInput.value, + resultText: resultOutput.value, + excludePreviousWinners: excludePreviousWinnersInput.checked, + autoRemoveDuplicates: autoRemoveDuplicatesInput.checked, + previousWinners: state.previousWinners, + lastUsedParticipants: state.lastUsedParticipants, + }; + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } -function copiarDatos(event) { - event.preventDefault() - if (destino.value === "") { - swal({ - text: 'No hay resultado para copiar!', - icon: "warning" - }); - } else { - navigator.clipboard.writeText(destino.value).then(function () { - swal({ - text: 'Se copió el resultado!', - icon: "success" - }); - }, function (err) { - swal({ - text: 'No se pudo copiar el resultado (ver consola)', - icon: "error" - }); - console.log(err) +function showToast(message, tone = "success") { + window.clearTimeout(toastTimeoutId); + toast.textContent = message; + toast.dataset.tone = tone; + toast.classList.add("show"); + + toastTimeoutId = window.setTimeout(() => { + toast.classList.remove("show"); + }, TOAST_DURATION_MS); +} + +function requestConfirmation(message) { + confirmMessage.textContent = message; + confirmDialog.hidden = false; + confirmCancelButton.focus(); + + return new Promise((resolve) => { + pendingConfirmationResolve = resolve; + }); +} + +function closeConfirmation(confirmed) { + confirmDialog.hidden = true; + + if (pendingConfirmationResolve) { + pendingConfirmationResolve(confirmed); + pendingConfirmationResolve = null; + } +} + +function getCurrentParticipants() { + return parseParticipants(participantsInput.value); +} + +function getParticipantsOverview() { + const participants = getCurrentParticipants(); + const duplicates = getDuplicateEntries(participants); + const uniqueParticipants = dedupeParticipants(participants); + const eligibleParticipants = buildEligibleParticipants( + uniqueParticipants, + state.previousWinners, + excludePreviousWinnersInput.checked, + ); + + return { + participants, + duplicates, + uniqueParticipants, + eligibleParticipants, + }; +} + +function setFieldError(element, input, message) { + element.textContent = message; + input.classList.toggle("input-invalid", Boolean(message)); + input.setAttribute("aria-invalid", message ? "true" : "false"); +} + +function renderMetrics(overview) { + const chips = []; + + if (overview.duplicates.length > 0) { + const duplicateCount = overview.duplicates.reduce((total, duplicate) => total + duplicate.count - 1, 0); + chips.push(`${duplicateCount} repetidos`); + } + + if (excludePreviousWinnersInput.checked && state.previousWinners.length > 0) { + chips.push(`${overview.eligibleParticipants.length} disponibles`); + } + + participantsMetrics.innerHTML = chips.join(""); +} + +function updateSummary() { + const prizes = sanitizePrizeCount(prizesInput.value); + const overview = getParticipantsOverview(); + const total = overview.participants.length; + const label = total === 1 ? "participante" : "participantes"; + const duplicateEntriesCount = overview.duplicates.reduce((totalDuplicates, duplicate) => { + return totalDuplicates + duplicate.count - 1; + }, 0); + + participantsCounter.textContent = `${total} ${label}`; + summaryPrizes.textContent = String(prizes); + summaryParticipants.textContent = String(overview.eligibleParticipants.length); + summaryDuplicateParticipants.textContent = String(duplicateEntriesCount); + historyText.textContent = state.previousWinners.length + ? state.previousWinners.join(", ") + : "Todavía no se excluyeron ganadores previos."; + removeDuplicatesButton.disabled = overview.duplicates.length === 0; + rerollButton.disabled = overview.uniqueParticipants.length === 0; + exportButton.disabled = !resultOutput.value.trim(); + copyButton.disabled = !resultOutput.value.trim(); + clearHistoryButton.disabled = state.previousWinners.length === 0; + + renderMetrics(overview); + persistState(); +} + +function renderResultState(hasResult) { + emptyState.classList.toggle("empty-state-hidden", hasResult); + resultBadge.textContent = hasResult ? "Ultimo sorteo listo" : "Sin sorteo"; + resultBadge.className = `counter ${hasResult ? "counter-success" : "counter-neutral"}`; +} + +function extractWinnersFromResultText(resultText) { + return String(resultText) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const separatorIndex = line.indexOf(":"); + return separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line; }); +} + +function renderWinnerReveal(winners) { + if (!winners.length) { + winnerReveal.innerHTML = ""; + winnerReveal.classList.remove("has-winners"); + return; + } + + winnerReveal.classList.add("has-winners"); + winnerReveal.innerHTML = winners.map((winner, index) => { + const label = winners.length === 1 ? "Ganador" : `Premio ${index + 1}`; + const singleClass = winners.length === 1 ? " single" : ""; + const delay = `${index * 90}ms`; + + return ` +
+ ${label} + ${winner} +
+ `; + }).join(""); +} + +function validateFormForUI() { + const prizes = sanitizePrizeCount(prizesInput.value); + const overview = getParticipantsOverview(); + const validation = validateRaffle({ + participants: autoRemoveDuplicatesInput.checked ? overview.uniqueParticipants : overview.participants, + prizeCount: prizes, + previousWinners: state.previousWinners, + excludePreviousWinners: excludePreviousWinnersInput.checked, + }); + + setFieldError(prizeError, prizesInput, prizes >= MIN_PRIZES ? "" : "La cantidad de premios es inválida."); + setFieldError( + participantsError, + participantsInput, + validation.valid ? "" : validation.message, + ); + + return validation; +} + +function animateDraw() { + resultOutput.classList.remove("is-drawing"); + window.clearTimeout(drawAnimationTimeoutId); + void resultOutput.offsetWidth; + resultOutput.classList.add("is-drawing"); + drawAnimationTimeoutId = window.setTimeout(() => { + resultOutput.classList.remove("is-drawing"); + }, DRAW_ANIMATION_MS); +} + +function syncPanelTopHeights() { + panelTops.forEach((panelTop) => { + panelTop.style.minHeight = ""; + }); + + if (window.innerWidth <= 640 || panelTops.length === 0) { + return; } + + const tallestHeight = Math.max(...Array.from(panelTops, (panelTop) => panelTop.offsetHeight)); + + panelTops.forEach((panelTop) => { + panelTop.style.minHeight = `${tallestHeight}px`; + }); } -function borrarDatos() { - destino.value = "" - cantPremios.value = "1" +function performRaffle(options) { + const prizes = sanitizePrizeCount(prizesInput.value); + const overview = getParticipantsOverview(); + const sourceParticipants = options.useCurrentText + ? (autoRemoveDuplicatesInput.checked ? overview.uniqueParticipants : overview.participants) + : state.lastUsedParticipants; + const validation = validateRaffle({ + participants: sourceParticipants, + prizeCount: prizes, + previousWinners: state.previousWinners, + excludePreviousWinners: excludePreviousWinnersInput.checked, + }); + + prizesInput.value = String(prizes); + updateSummary(); + validateFormForUI(); + + if (!validation.valid) { + resultOutput.value = ""; + renderWinnerReveal([]); + renderResultState(false); + showToast(validation.message, "error"); + return false; + } + + const excludedParticipants = excludePreviousWinnersInput.checked ? state.previousWinners : []; + const winners = pickWinners(sourceParticipants, prizes, excludedParticipants); + resultOutput.value = formatWinners(winners); + renderWinnerReveal(winners); + state.lastUsedParticipants = [...sourceParticipants]; + + if (excludePreviousWinnersInput.checked) { + state.previousWinners = dedupeParticipants([...state.previousWinners, ...winners]); + } + + renderResultState(true); + updateSummary(); + animateDraw(); + showToast(options.toastMessage, "success"); + return true; } -// funciones del sorteo +function handlePrizeInput() { + prizesInput.value = String(sanitizePrizeCount(prizesInput.value)); + validateFormForUI(); + updateSummary(); +} + +function handleParticipantsInput() { + validateFormForUI(); + updateSummary(); +} -function getRandom(arr, n) { - var result = new Array(n), - len = arr.length, - taken = new Array(len); - if (n > len) - throw new RangeError(swal({ - text: "La cantidad de participantes es menor a los premios.", - icon: "error" - })); - while (n--) { - var x = Math.floor(Math.random() * len); - result[n] = arr[x in taken ? taken[x] : x]; - taken[x] = --len in taken ? taken[len] : len; +function handleSubmit(event) { + event.preventDefault(); + performRaffle({ + useCurrentText: true, + toastMessage: "Sorteo realizado con éxito.", + }); +} + +function handleReroll() { + const overview = getParticipantsOverview(); + + if (overview.uniqueParticipants.length === 0) { + showToast("Primero cargá participantes para poder volver a sortear.", "warning"); + return; } - return result; + + if (state.lastUsedParticipants.length === 0) { + state.lastUsedParticipants = autoRemoveDuplicatesInput.checked ? overview.uniqueParticipants : overview.participants; + } + + performRaffle({ + useCurrentText: false, + toastMessage: "Nuevo sorteo generado.", + }); } -function ejecutarCaptura(event) { - event.preventDefault() - let premios = parseInt(cantPremios.value) - let captura = (origen.value).split('\n').map(e => e.trim()).filter(e => e) - let resultado = getRandom(captura, premios) +async function handleCopy() { + if (!resultOutput.value.trim()) { + showToast("Todavía no hay resultado para copiar.", "warning"); + return; + } - return ( - destino.value = resultado.map((value, index) => `Premio ${(index+1)}: ${value}`).join('\n') - ) + try { + await navigator.clipboard.writeText(resultOutput.value); + showToast("Resultado copiado al portapapeles.", "success"); + } catch (error) { + console.error(error); + showToast("No se pudo copiar el resultado.", "error"); + } } + +function handleExport() { + if (!resultOutput.value.trim()) { + showToast("Todavía no hay resultado para exportar.", "warning"); + return; + } + + const blob = new Blob([resultOutput.value], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = "resultado-sorteo.txt"; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + + showToast("Resultado exportado en TXT.", "success"); +} + +function handleRemoveDuplicates() { + const participants = getCurrentParticipants(); + const uniqueParticipants = dedupeParticipants(participants); + + if (participants.length === uniqueParticipants.length) { + showToast("No hay participantes repetidos para limpiar.", "warning"); + return; + } + + participantsInput.value = uniqueParticipants.join("\n"); + validateFormForUI(); + updateSummary(); + showToast("Se eliminaron los participantes repetidos.", "success"); +} + +async function handleClearHistory() { + const confirmed = await requestConfirmation("Se va a borrar el historial de ganadores excluidos. Después vas a poder volver a usar esos nombres en próximos sorteos."); + + if (!confirmed) { + return; + } + + state.previousWinners = []; + validateFormForUI(); + updateSummary(); + showToast("Se borró el historial de ganadores excluidos.", "success"); +} + +async function handleReset(event) { + event.preventDefault(); + const hasContent = Boolean( + participantsInput.value.trim() || + resultOutput.value.trim() || + state.previousWinners.length > 0 || + excludePreviousWinnersInput.checked || + autoRemoveDuplicatesInput.checked, + ); + + if (!hasContent) { + raffleForm.reset(); + prizesInput.value = String(MIN_PRIZES); + renderWinnerReveal([]); + renderResultState(false); + updateSummary(); + return; + } + + const confirmed = await requestConfirmation("Se van a limpiar participantes, resultado y opciones activas. Esta acción no se puede deshacer."); + + if (!confirmed) { + return; + } + + window.requestAnimationFrame(() => { + raffleForm.reset(); + prizesInput.value = String(MIN_PRIZES); + participantsInput.value = ""; + resultOutput.value = ""; + renderWinnerReveal([]); + excludePreviousWinnersInput.checked = false; + autoRemoveDuplicatesInput.checked = false; + state.previousWinners = []; + state.lastUsedParticipants = []; + setFieldError(prizeError, prizesInput, ""); + setFieldError(participantsError, participantsInput, ""); + renderResultState(false); + updateSummary(); + showToast("Formulario reiniciado.", "success"); + }); +} + +loadState(); +raffleForm.addEventListener("submit", handleSubmit); +prizesInput.addEventListener("input", handlePrizeInput); +participantsInput.addEventListener("input", handleParticipantsInput); +excludePreviousWinnersInput.addEventListener("change", () => { + validateFormForUI(); + updateSummary(); +}); +autoRemoveDuplicatesInput.addEventListener("change", () => { + validateFormForUI(); + updateSummary(); +}); +copyButton.addEventListener("click", handleCopy); +exportButton.addEventListener("click", handleExport); +resetButton.addEventListener("click", handleReset); +rerollButton.addEventListener("click", handleReroll); +clearHistoryButton.addEventListener("click", handleClearHistory); +removeDuplicatesButton.addEventListener("click", handleRemoveDuplicates); +confirmCancelButton.addEventListener("click", () => closeConfirmation(false)); +confirmAcceptButton.addEventListener("click", () => closeConfirmation(true)); +confirmDialog.addEventListener("click", (event) => { + if (event.target === confirmDialog) { + closeConfirmation(false); + } +}); +window.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !confirmDialog.hidden) { + closeConfirmation(false); + } +}); +window.addEventListener("load", syncPanelTopHeights); +window.addEventListener("resize", syncPanelTopHeights); + +prizesInput.value = String(sanitizePrizeCount(prizesInput.value)); +renderWinnerReveal(extractWinnersFromResultText(resultOutput.value)); +renderResultState(Boolean(resultOutput.value.trim())); +validateFormForUI(); +updateSummary(); +syncPanelTopHeights(); diff --git a/site.webmanifest b/site.webmanifest new file mode 100644 index 0000000..b890ab7 --- /dev/null +++ b/site.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Sorteo Simple", + "short_name": "Sorteo", + "description": "Herramienta simple para sortear ganadores de forma aleatoria.", + "start_url": "./", + "display": "standalone", + "background_color": "#fff7ed", + "theme_color": "#ef7c41", + "icons": [ + { + "src": "favicon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} diff --git a/styles.css b/styles.css index 2add079..1c4212b 100644 --- a/styles.css +++ b/styles.css @@ -1,62 +1,782 @@ +:root { + --bg-top: #fff7ed; + --bg-bottom: #ecfeff; + --panel: rgba(255, 255, 255, 0.8); + --panel-border: rgba(148, 163, 184, 0.22); + --text-main: #14213d; + --text-muted: #52607a; + --text-soft: #74829d; + --accent: #ef7c41; + --accent-strong: #dd5f22; + --accent-soft: #ffe2d1; + --secondary: #0f766e; + --secondary-soft: #d9f6f2; + --neutral-soft: #eef2ff; + --danger: #b91c1c; + --danger-soft: #fee2e2; + --shadow-lg: 0 24px 60px rgba(20, 33, 61, 0.16); + --shadow-md: 0 16px 34px rgba(20, 33, 61, 0.1); + --radius-xl: 28px; + --radius-lg: 18px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + min-height: 100%; +} + body { - background-image: linear-gradient(to bottom, #f4f9ff, #f1f5fe, #eef1fd, #ededfb, #ede9f9); - background-repeat: no-repeat; - background-attachment: fixed; + margin: 0; + min-height: 100vh; + font-family: "Plus Jakarta Sans", sans-serif; + color: var(--text-main); + background: + radial-gradient(circle at top left, rgba(239, 124, 65, 0.22), transparent 28%), + radial-gradient(circle at bottom right, rgba(15, 118, 110, 0.18), transparent 30%), + linear-gradient(160deg, var(--bg-top), var(--bg-bottom)); +} + +body::before, +body::after { + content: ""; + position: fixed; + z-index: 0; + border-radius: 999px; + filter: blur(12px); + pointer-events: none; +} + +body::before { + top: 64px; + right: 8%; + width: 240px; + height: 240px; + background: rgba(251, 146, 60, 0.18); +} + +body::after { + bottom: 80px; + left: 4%; + width: 280px; + height: 280px; + background: rgba(45, 212, 191, 0.14); +} + +.page-shell { + position: relative; + z-index: 1; + width: min(1200px, calc(100% - 32px)); + margin: 0 auto; + padding: 40px 0 24px; +} + +.app { + display: grid; + gap: 28px; +} + +.hero { + padding: 18px 6px 4px; +} + +.hero-title { + display: flex; + align-items: center; + gap: 16px; +} + +.hero-mark { + width: clamp(44px, 6vw, 62px); + height: clamp(44px, 6vw, 62px); + flex: 0 0 auto; + border-radius: 16px; + box-shadow: 0 12px 28px rgba(221, 95, 34, 0.18); +} + +.eyebrow { + margin: 0 0 14px; + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--secondary); +} + +.hero h1 { + margin: 0; + font-size: clamp(2.2rem, 4.6vw, 3.9rem); + line-height: 0.98; + letter-spacing: -0.05em; +} + +.hero-copy { + width: min(680px, 100%); + margin: 14px 0 0; + font-size: 0.98rem; + line-height: 1.65; + color: var(--text-muted); +} + +.workspace { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; + align-items: stretch; +} + +.panel { + display: flex; + flex-direction: column; + gap: 20px; + min-height: 100%; + background: var(--panel); + backdrop-filter: blur(18px); + border: 1px solid var(--panel-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + padding: 28px; +} + +.panel-heading { + display: grid; + gap: 8px; + min-height: 92px; +} + +.panel-heading h2 { + margin: 0; + font-size: 1.42rem; +} + +.panel-heading p { + margin: 0; + color: var(--text-muted); + line-height: 1.6; +} + +.panel-form form { + display: flex; + flex: 1; + flex-direction: column; + gap: 18px; + align-items: stretch; +} + +.panel-top { + display: flex; + align-items: flex-start; + width: 100%; +} + +.panel-form .panel-top { + justify-content: center; +} + +.panel-result .panel-top { + justify-content: center; +} + +.options-card, +.history-card { + padding: 16px 18px; + border-radius: 22px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(255, 255, 255, 0.55); +} + +.options-card { + padding: 0; + overflow: hidden; +} + +.options-card summary { + padding: 16px 18px; + font-weight: 700; + cursor: pointer; + list-style: none; } -h1 { - padding-top: 1rem; - color: #0074D9; +.options-card summary::-webkit-details-marker { + display: none; } -.form-tickets { - padding-top: 3rem; - padding-bottom: 3rem; +.options-content { + display: grid; + gap: 12px; + padding: 0 18px 18px; } -.form-control { +.toggle { + display: flex; + gap: 10px; + align-items: flex-start; + color: var(--text-muted); + line-height: 1.5; +} + +.toggle + .toggle { + margin-top: 12px; +} + +.toggle input { + margin-top: 4px; +} + +.field-group { + display: grid; + gap: 10px; +} + +.field-group-fill { + display: flex; + flex: 1; + flex-direction: column; +} + +.field-group.compact { + width: min(100%, 320px); + max-width: none; + margin-inline: auto; +} + +.panel-form .field-group.compact label, +.panel-form .field-group.compact small, +.panel-form .field-group.compact .field-error { text-align: center; } +.field-group label, +.summary-label { + font-size: 0.94rem; + font-weight: 700; +} + +.field-group small { + color: var(--text-muted); + line-height: 1.5; +} + +.field-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + min-height: 34px; +} + +.meta-row { + display: flex; + justify-content: center; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.meta-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.meta-chip, +.counter { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 6px 10px; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 700; +} + +.counter { + color: var(--secondary); + background: var(--secondary-soft); +} + +.counter-neutral { + color: var(--text-muted); + background: var(--neutral-soft); +} + +.counter-success { + color: var(--secondary); + background: var(--secondary-soft); +} + +.counter-warning, +.meta-chip.warning { + color: #92400e; + background: #ffedd5; +} + +.meta-chip { + color: var(--text-muted); + background: rgba(255, 255, 255, 0.84); + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.field-error { + min-height: 1.2em; + margin: 0; + color: var(--danger); + font-size: 0.88rem; + font-weight: 600; +} + +input[type="number"], +.form-textarea { + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.88); + color: var(--text-main); + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +input[type="number"] { + min-height: 54px; + padding: 0 16px; + font: inherit; +} + +.form-textarea { + min-height: 300px; + flex: 1; + padding: 16px 18px; + resize: vertical; + font: inherit; + line-height: 1.6; +} -.subTextarea { - padding-bottom: 1rem; - font-size: 1.125rem; - font-weight: bold; - color: #F012BE; +input[type="number"]:focus, +.form-textarea:focus { + outline: none; + border-color: rgba(239, 124, 65, 0.72); + box-shadow: 0 0 0 4px rgba(239, 124, 65, 0.14); + transform: translateY(-1px); } -.btnEjecutar { - padding-top: 1rem; +.input-invalid { + border-color: rgba(185, 28, 28, 0.4); + box-shadow: 0 0 0 4px rgba(185, 28, 28, 0.08); } -input::-webkit-outer-spin-button, -input::-webkit-inner-spin-button { - -webkit-appearance: none; +.result-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + width: 100%; +} + +.result-top { + display: grid; + gap: 10px; + width: min(100%, 560px); + margin-inline: auto; +} + +.result-summary div { + display: grid; + gap: 8px; + padding: 16px; + border-radius: 20px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.65)); + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.result-summary strong { + font-size: 1.6rem; + letter-spacing: -0.03em; +} + +.result-meta { + display: flex; + gap: 18px; + flex-wrap: wrap; margin: 0; + color: var(--text-muted); + justify-content: center; +} + +.result-meta strong { + color: var(--text-main); +} + +.result-stage { + position: relative; + display: flex; + flex: 1; +} + +.winner-reveal { + display: none; } -input[type=number] { - -moz-appearance: textfield; +.winner-reveal.has-winners { + display: flex; + flex-wrap: wrap; + gap: 10px; + perspective: 900px; +} + +.winner-card { + flex: 1 1 180px; + display: grid; + gap: 6px; + padding: 14px 16px; + border-radius: 18px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(255, 242, 234, 0.95)); + border: 1px solid rgba(239, 124, 65, 0.2); + box-shadow: 0 14px 28px rgba(20, 33, 61, 0.08); + opacity: 0; + transform: translateY(14px) rotateX(-10deg) scale(0.97); + animation: revealWinner 620ms cubic-bezier(.2, .8, .2, 1) forwards; + animation-delay: var(--delay, 0ms); +} + +.winner-card strong { + font-size: 1rem; + line-height: 1.35; +} + +.winner-card span { + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent-strong); +} + +.winner-card.single { + flex-basis: 100%; + justify-items: center; + text-align: center; + padding-block: 18px; +} + +@keyframes revealWinner { + from { + opacity: 0; + transform: translateY(14px) rotateX(-10deg) scale(0.97); + } + + 65% { + opacity: 1; + transform: translateY(-2px) rotateX(0deg) scale(1.01); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.empty-state { + position: absolute; + inset: 18px; + display: grid; + place-content: center; + gap: 10px; + padding: 24px; + text-align: center; + border-radius: calc(var(--radius-lg) - 2px); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 247, 237, 0.84)); + color: var(--text-muted); + pointer-events: none; + transition: opacity 180ms ease, transform 180ms ease; +} + +.empty-state strong { + color: var(--text-main); + font-size: 1.05rem; +} + +.empty-state-hidden { + opacity: 0; + transform: scale(0.985); +} + +.result-area { + position: relative; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 247, 237, 0.92)); +} + +.result-area.is-drawing { + animation: pulseDraw 620ms ease; +} + +@keyframes pulseDraw { + 0% { + transform: scale(0.995); + box-shadow: 0 0 0 0 rgba(239, 124, 65, 0.18); + } + + 50% { + transform: scale(1); + box-shadow: 0 0 0 8px rgba(239, 124, 65, 0.1); + } + + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(239, 124, 65, 0); + } +} + +.history-card p { + margin: 0; + color: var(--text-muted); + line-height: 1.5; text-align: center; - width: 50px; } -p { - color: #639fe2; - font-weight: bold; +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: auto; + justify-content: center; +} + +.btn { + appearance: none; + border: 0; + border-radius: 999px; + min-height: 50px; + padding: 0 18px; + font: inherit; + font-weight: 700; + cursor: pointer; + transition: transform 160ms ease, box-shadow 160ms ease, background-color 160ms ease, color 160ms ease, + border-color 160ms ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn:focus-visible { + outline: 3px solid rgba(20, 33, 61, 0.18); + outline-offset: 3px; +} + +.btn-primary { + color: #fff; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + box-shadow: 0 14px 28px rgba(221, 95, 34, 0.28); +} + +.btn-secondary { + color: var(--text-main); + background: rgba(255, 255, 255, 0.92); + box-shadow: var(--shadow-md); +} + +.btn-tertiary { + color: var(--secondary); + background: var(--secondary-soft); + box-shadow: 0 12px 24px rgba(15, 118, 110, 0.14); +} + +.btn-ghost { + color: var(--text-main); + background: transparent; + border: 1px solid rgba(148, 163, 184, 0.28); +} + +.btn[disabled] { + opacity: 0.55; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.site-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 18px; + padding: 18px 6px 8px; +} + +.site-footer p { + margin: 0; + font-weight: 700; + color: var(--text-muted); +} + +.footer-links { + display: flex; + gap: 12px; +} + +.footer-links a { + display: inline-flex; + justify-content: center; + align-items: center; + width: 46px; + height: 46px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); + box-shadow: var(--shadow-md); + transition: transform 160ms ease, box-shadow 160ms ease; +} + +.footer-links a:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.footer-links img { + width: 22px; + height: 22px; +} + +.toast { + position: fixed; + z-index: 30; + right: 18px; + bottom: 18px; + max-width: min(380px, calc(100% - 36px)); + padding: 14px 16px; + border-radius: 18px; + background: rgba(20, 33, 61, 0.94); + color: #fff; + box-shadow: var(--shadow-lg); + opacity: 0; + transform: translateY(14px); + pointer-events: none; + transition: opacity 180ms ease, transform 180ms ease; +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +.toast[data-tone="success"] { + background: rgba(15, 118, 110, 0.96); +} + +.toast[data-tone="error"] { + background: rgba(185, 28, 28, 0.96); +} + +.toast[data-tone="warning"] { + background: rgba(146, 64, 14, 0.96); +} + +.confirm-dialog { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + padding: 18px; + background: rgba(20, 33, 61, 0.2); + backdrop-filter: blur(8px); +} + +.confirm-dialog[hidden] { + display: none; +} + +.confirm-card { + width: min(100%, 420px); + padding: 22px; + border-radius: 24px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(148, 163, 184, 0.18); + box-shadow: var(--shadow-lg); +} + +.confirm-title { + margin: 0 0 8px; + font-size: 1.08rem; + font-weight: 800; } -a { - text-decoration: none !important; +.confirm-message { + margin: 0; + color: var(--text-muted); + line-height: 1.6; +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 18px; } -a:-webkit-any-link { - text-decoration: none; +@media (max-width: 1080px) { + .workspace { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .panel { + padding: 22px; + } } -@media only screen and (max-width: 768px) { - .btnEjecutar { - padding-bottom: 1.125rem; +@media (max-width: 640px) { + .page-shell { + width: min(100% - 20px, 1200px); + padding-top: 20px; + } + + .hero { + padding-inline: 2px; + } + + .hero-title { + gap: 12px; + } + + .hero-copy { + font-size: 0.98rem; + } + + .panel { + border-radius: 24px; + padding: 18px; + } + + .field-header, + .meta-row, + .site-footer { + display: grid; + justify-content: start; + } + + .result-summary { + grid-template-columns: 1fr; + } + + .panel-top { + min-height: auto !important; } -} \ No newline at end of file + + .field-group.compact { + max-width: none; + } + + .actions .btn, + .meta-row .btn { + width: 100%; + } + + .site-footer { + justify-content: center; + text-align: center; + } +} diff --git a/tests/raffle-core.test.js b/tests/raffle-core.test.js new file mode 100644 index 0000000..f95bcbd --- /dev/null +++ b/tests/raffle-core.test.js @@ -0,0 +1,92 @@ +const assert = require("node:assert/strict"); +const { + sanitizePrizeCount, + parseParticipants, + getDuplicateEntries, + dedupeParticipants, + buildEligibleParticipants, + validateRaffle, + pickWinners, + formatWinners, +} = require("../raffle-core.js"); + +function testSanitizePrizeCount() { + assert.equal(sanitizePrizeCount(""), 1); + assert.equal(sanitizePrizeCount("0"), 1); + assert.equal(sanitizePrizeCount("105"), 100); + assert.equal(sanitizePrizeCount("12abc"), 12); +} + +function runTest(name, testFn) { + testFn(); + console.log(`OK ${name}`); +} + +function testParseAndDeduplication() { + const participants = parseParticipants("Ana\n\nJuan \n Ana\n"); + + assert.deepEqual(participants, ["Ana", "Juan", "Ana"]); + assert.deepEqual(dedupeParticipants(participants), ["Ana", "Juan"]); + assert.deepEqual(getDuplicateEntries(participants), [{ name: "Ana", count: 2 }]); +} + +function testEligibleParticipants() { + const eligible = buildEligibleParticipants( + ["Ana", "Juan", "Pedro"], + ["Juan"], + true, + ); + + assert.deepEqual(eligible, ["Ana", "Pedro"]); +} + +function testValidation() { + const invalidByCount = validateRaffle({ + participants: ["Ana"], + prizeCount: 2, + previousWinners: [], + excludePreviousWinners: false, + }); + const invalidByExclusion = validateRaffle({ + participants: ["Ana"], + prizeCount: 1, + previousWinners: ["Ana"], + excludePreviousWinners: true, + }); + const valid = validateRaffle({ + participants: ["Ana", "Juan"], + prizeCount: 1, + previousWinners: [], + excludePreviousWinners: false, + }); + + assert.equal(invalidByCount.valid, false); + assert.equal(invalidByExclusion.valid, false); + assert.equal(valid.valid, true); +} + +function testPickWinnersAndFormat() { + const winners = pickWinners(["Ana", "Juan", "Pedro"], 2, ["Pedro"]); + + assert.equal(winners.length, 2); + assert.ok(winners.every((winner) => ["Ana", "Juan"].includes(winner))); + assert.match(formatWinners(["Ana"]), /Premio 1: Ana/); +} + +function testPickWinnersRespectsExcludedPreviousWinners() { + const winners = pickWinners(["Ana", "Juan", "Pedro"], 1, ["Ana", "Juan"]); + + assert.deepEqual(winners, ["Pedro"]); +} + +function run() { + runTest("sanitizePrizeCount", testSanitizePrizeCount); + runTest("parseAndDeduplication", testParseAndDeduplication); + runTest("eligibleParticipants", testEligibleParticipants); + runTest("validation", testValidation); + runTest("pickWinnersAndFormat", testPickWinnersAndFormat); + runTest("pickWinnersRespectsExcludedPreviousWinners", testPickWinnersRespectsExcludedPreviousWinners); + console.log("All raffle core tests passed."); +} + +run();