commit 19f01c4c54bd9be886df03755f17d80cd5353e53 Author: Isaac_Aracena Date: Sat May 9 11:56:04 2026 -0400 feat: Initial commit - Ruleta con PIN (credenciales sanitizadas) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..859d818 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# ─── Credenciales y configuración local ─── +config.php +.env + +# ─── Sistema operativo ─── +Thumbs.db +.DS_Store +desktop.ini + +# ─── IDEs y editores ─── +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# ─── PHP ─── +vendor/ +composer.lock + +# ─── Archivos temporales ─── +*.log +*.tmp +*.bak + +# ─── ZIP original del proyecto ─── +*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bf884c --- /dev/null +++ b/README.md @@ -0,0 +1,232 @@ +# 🎰 Ruleta con PIN + +Aplicación web de **ruleta de premios** protegida por PIN, con registro de resultados vía backend PHP y base de datos MySQL/MariaDB. + +Desarrollada para campañas promocionales de **Gomez Lee Marketing (GLM)**. + +--- + +## 📋 Tabla de Contenidos + +- [Características](#-características) +- [Requisitos](#-requisitos) +- [Instalación](#-instalación) +- [Configuración de Base de Datos](#-configuración-de-base-de-datos) +- [Configuración de PINs](#-configuración-de-pins) +- [Estructura del Proyecto](#-estructura-del-proyecto) +- [Flujo del Sistema](#-flujo-del-sistema) +- [Operación Diaria](#-operación-diaria) +- [Solución de Problemas](#-solución-de-problemas) + +--- + +## ✨ Características + +- **Autenticación por PIN** antes de girar la ruleta +- **Selección aleatoria ponderada** de premios según probabilidades configurables +- **Control de stock** por premio y cupo global de giros +- **Registro de auditoría** con IP, user agent y timestamp +- **Animación fluida** de ruleta con CSS transitions +- **Efecto confeti** al ganar (canvas-confetti) +- **Diseño responsive** con soporte de pantalla completa +- **Redirección a formulario JotForm** para reclamar el premio + +--- + +## 📦 Requisitos + +| Componente | Versión mínima | +|---|---| +| PHP | 8.0+ con extensión `mysqli` | +| MySQL / MariaDB | 5.7+ / 10.3+ | +| Servidor Web | Apache (XAMPP) o Nginx | +| Navegador | Cualquier navegador moderno con JS habilitado | + +> La aplicación requiere acceso HTTP saliente a `https://api.ipify.org` para obtener la IP pública del usuario. + +--- + +## 🚀 Instalación + +### 1. Clonar el repositorio + +```bash +git clone https://git.digitalcompass.agency/Isaac_Aracena/ruleta-con-pin.git +``` + +### 2. Colocar en el servidor web + +- **Windows (XAMPP):** Copiar a `C:\xampp\htdocs\ruleta\` +- **Linux (Apache/Nginx):** Copiar a `/var/www/ruleta/` + +### 3. Configurar credenciales de BD + +```bash +cp config.example.php config.php +``` + +Editar `config.php` con los datos reales: + +```php +$servername = "localhost"; +$username = "tu_usuario"; +$password = "tu_contraseña"; +$dbname = "ruleta_db"; +``` + +> ⚠️ **`config.php` está en `.gitignore`** y nunca se versiona. Las credenciales quedan solo en tu servidor. + +### 4. Crear la base de datos + +Ejecutar el script SQL incluido: + +```bash +mysql -u root -p < database/schema.sql +``` + +### 5. Cargar premios y PINs + +Ver sección [Configuración de PINs](#-configuración-de-pins). + +### 6. Probar + +Abrir `http://localhost/ruleta/index.html` e ingresar un PIN válido. + +--- + +## 🗄️ Configuración de Base de Datos + +### Tablas + +| Tabla | Descripción | +|---|---| +| `girospermitidos` | Control global de cupo de giros (1 registro, id=1) | +| `premiosnew` | Premios con stock, probabilidad y PIN asignado | +| `premiosentregados` | Auditoría de premios entregados | + +### Ajustar cupo de giros + +```sql +UPDATE girospermitidos SET cantidad = 50000 WHERE id = 1; +``` + +### Cargar un premio + +```sql +INSERT INTO premiosnew (nombre, probabilidad, cantidad, inicio, final, nombre_usuario) +VALUES ('Gorra', 1.0, 10, 0, 45, '1989'); +``` + +- `inicio` / `final` = ángulos en la imagen de la ruleta (0–360°) +- `nombre_usuario` = el PIN que podrá ganar este premio +- `cantidad` = stock disponible + +--- + +## 🔑 Configuración de PINs + +Los PINs deben estar sincronizados en **dos lugares**: + +1. **Base de datos:** columna `premiosnew.nombre_usuario` +2. **Frontend:** array `validPins` en `script.js` (línea 35) + +```javascript +const validPins = ["1989", "4708", "6823", "7591", ...]; +``` + +> Si un PIN está en la BD pero no en `validPins`, el modal del frontend no dejará pasar. +> Si un PIN está en `validPins` pero no en la BD, el backend responderá "No hay premios disponibles". + +--- + +## 📁 Estructura del Proyecto + +``` +ruleta-con-pin/ +├── index.html # Página principal (modal PIN + ruleta) +├── script.js # Lógica del juego, validación, animación +├── style.css # Estilos, responsive, modales +├── seleccionar_premio.php # Backend: selección aleatoria + registro BD +├── config.example.php # Plantilla de configuración de BD +├── config.php # ⛔ Credenciales reales (no versionado) +├── database/ +│ └── schema.sql # Script de creación de BD y tablas +└── img/ + ├── background.png # Fondo de la página + ├── ruleta.png # Imagen de la ruleta + ├── puntero.png # Flecha/puntero de la ruleta + ├── fullscreen-icon.png + ├── BANNER-1.png # Logo GLM + ├── BANNER-2.png # Banner lateral + └── BANNER-4.png # Banner inferior +``` + +--- + +## 🔄 Flujo del Sistema + +``` +Usuario → Ingresa PIN → Validación frontend (validPins) + → Muestra ruleta → Click en puntero → Obtiene IP (ipify) + → POST a seleccionar_premio.php + → Verifica cupo global (girospermitidos) + → Selecciona premio aleatorio ponderado (premiosnew) + → Descuenta stock + registra en premiosentregados + → Retorna JSON {id, nombre, inicio, final} + → Anima ruleta al ángulo del premio + → Confeti 🎊 + Modal "¡Ganaste!" + → Botón "Ir al formulario" → JotForm +``` + +--- + +## 📊 Operación Diaria + +### Agregar PINs/premios + +1. Insertar en `premiosnew` con el PIN deseado +2. Agregar el PIN al array `validPins` en `script.js` + +### Consultar premios entregados + +```sql +SELECT * FROM premiosentregados ORDER BY created_at DESC; +``` + +### Consultar stock restante + +```sql +SELECT nombre, nombre_usuario, cantidad FROM premiosnew; +``` + +### Aumentar cupo de giros + +```sql +UPDATE girospermitidos SET cantidad = cantidad + 1000 WHERE id = 1; +``` + +--- + +## 🔧 Solución de Problemas + +| Problema | Causa | +|---|---| +| "Conexión fallida…" | Credenciales en `config.php` incorrectas o BD inaccesible | +| "No hay premios disponibles" | No hay fila en `premiosnew` con ese PIN y `cantidad > 0` | +| No pasa del modal de PIN | El PIN no está en `validPins` en `script.js` | +| No registra IP | Navegador no puede acceder a `https://api.ipify.org` | +| "Se ha alcanzado el límite de giros" | `girospermitidos.cantidad` llegó a 0 | + +--- + +## 🛡️ Notas de Seguridad + +- Las credenciales de BD se cargan desde `config.php` (no versionado) +- Los `INSERT` usan **prepared statements** para prevenir SQL injection +- La validación de PINs en el frontend es solo UX; la validación real ocurre en el backend vía `nombre_usuario` en la consulta SQL + +--- + +## 📄 Licencia + +Proyecto interno de **Gomez Lee Marketing**. Uso privado. diff --git a/config.example.php b/config.example.php new file mode 100644 index 0000000..4fb49ae --- /dev/null +++ b/config.example.php @@ -0,0 +1,9 @@ + + + + + GLM - Ruleta + + + + + + + + + + +
+

+
+ + +
+
+ + +
+ + +
+ Por favor, gira tu dispositivo para ver el contenido en horizontal. +
+ + + + + + + + + + + + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..c901393 --- /dev/null +++ b/script.js @@ -0,0 +1,216 @@ +var ruleta = document.getElementById("ruleta"); +var puntero = document.getElementById("puntero"); +let gira = 0; +let giraDos = 0; + +const rangos = [ + { articulo: "Freidora de aire", inicio: 330, final: 16 }, + { articulo: "Articulo plastico", inicio: 15, final: 16 }, + { articulo: "Sandwichera", inicio: 150, final: 196 }, + { articulo: "Kits Protex", inicio: 170, final: 16 }, + { articulo: "Cubeta", inicio: 20, final: 52 }, + { articulo: "Lonchera", inicio: 42, final: 88 }, + { articulo: "Recipiente plastico", inicio: 130, final: 160 }, + { articulo: "Kits Cuidado Oral", inicio: 220, final: 232 }, + { articulo: "Plancha", inicio: 150, final: 232 }, + { articulo: "Caldero", inicio: 30, final: 194 }, + { articulo: "Abanico de piso", inicio: 296, final: 328 }, +]; + +let articuloGanador = ""; + +// Variables para el modal y formulario +const modal = document.getElementById("popup-modal"); +const form = document.getElementById("user-form"); +const userNameInput = document.getElementById("user-name"); + +let nombreUsuario = ""; + +// Mostrar el modal al cargar la página +window.onload = function () { + modal.style.display = "flex"; +}; + +// Lista de PINs válidos +const validPins = ["1989", "4708", "6823", "7591", "3664", "3520", "4528", "9033", "3688", "6159", "2258"]; + +// Manejar el envío del formulario +form.addEventListener("submit", function (e) { + e.preventDefault(); + nombreUsuario = userNameInput.value.trim(); // Eliminar espacios en blanco + + if (!nombreUsuario) { + alert("Por favor, completa todos los campos."); + return; + } + + // Validar si el PIN es válido + if (!validPins.includes(nombreUsuario)) { + alert("PIN inválido. Por favor, inténtelo de nuevo."); + return; + } + + // Si el PIN es válido, ocultar el modal y continuar + modal.style.display = "none"; + document.querySelector(".content").style.display = "block"; // Mostrar el contenido principal +}); + +// Calcular el ángulo intermedio de un rango +function calcularAngulo(rango) { + if (rango.inicio > rango.final) { + return (rango.inicio + (360 - rango.inicio + rango.final) / 2) % 360; + } + return rango.inicio + (rango.final - rango.inicio) / 2; +} + +// Girar la ruleta a un artículo específico +function girarRuletaAArticulo(nombreArticulo) { + let rangoSeleccionado = rangos.find( + (rango) => rango.articulo === nombreArticulo + ); + + if (rangoSeleccionado) { + let anguloFinal = calcularAngulo(rangoSeleccionado); + gira = 1440 + anguloFinal; // 1440 asegura varias vueltas antes de detenerse en el artículo ganador + ruleta.style.transition = "all 5s ease-out"; + ruleta.style.transform = `rotate(${gira}deg)`; + } else { + console.log("Artículo no encontrado."); + } +} + +// Fin de la rodada +ruleta.addEventListener("transitionend", function () { + puntero.style.pointerEvents = "auto"; // Reactivar clics + ruleta.style.transition = "none"; + giraDos = gira % 360; + ruleta.style.transform = `rotate(${giraDos}deg)`; + + mostrarConfetiConTexto(articuloGanador); +}); + +// Obtener dirección IP +async function obtenerIP() { + const response = await fetch("https://api.ipify.org?format=json"); + const data = await response.json(); + return data.ip; +} + +// Obtener artículo ganador +async function obtenerArticuloGanador() { + const userAgent = navigator.userAgent; + const ipAddress = await obtenerIP(); + + fetch("seleccionar_premio.php", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user_agent: userAgent, + ip_address: ipAddress, + nombre_usuario: nombreUsuario, + }), + }) + .then((response) => response.json()) + .then((data) => { + articuloGanador = data.nombre; + girarRuletaAArticulo(articuloGanador); + }) + .catch((error) => + console.error("Error al obtener el artículo ganador:", error) + ); +} + +// Bloquear interacción con la ruleta mientras gira +puntero.addEventListener("mousedown", function () { + if (!nombreUsuario) { + alert("Por favor, completa el formulario antes de continuar."); + return; + } + + puntero.style.pointerEvents = "none"; // Bloquear clics + obtenerArticuloGanador(); +}); + +// Mostrar confeti con texto +function mostrarConfetiConTexto(texto) { + const duration = 5 * 1000; + const end = Date.now() + duration; + + (function frame() { + confetti({ + particleCount: 7, + angle: 60, + spread: 55, + origin: { x: 0 }, + scalar: 1.2, + }); + confetti({ + particleCount: 7, + angle: 120, + spread: 55, + origin: { x: 1 }, + scalar: 1.2, + }); + + if (Date.now() < end) { + requestAnimationFrame(frame); + } + })(); + + console.log("¡Felicidades! Ganaste: " + texto); +} + +const fullscreenBtn = document.getElementById("fullscreen-btn"); + +fullscreenBtn.addEventListener("click", () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch((err) => { + console.error( + `Error al intentar entrar en pantalla completa: ${err.message}` + ); + }); + } else { + document.exitFullscreen().catch((err) => { + console.error( + `Error al intentar salir de pantalla completa: ${err.message}` + ); + }); + } +}); + +// Mostrar el popup de agradecimiento +function mostrarMensajeAgradecimiento() { + const modal = document.getElementById("thank-you-modal"); + const mensaje = document.getElementById("thank-you-message"); + const detalles = document.getElementById("thank-you-details"); + const botonRedirigir = document.getElementById("redirect-button"); + + // Personalizar contenido del mensaje + mensaje.textContent = `🎊🎉¡Felicidades!🎉🎊`; + detalles.textContent = `🎁 Has ganado "${articuloGanador}" 🎁`; + + modal.style.display = "flex"; // Mostrar el modal + + botonRedirigir.addEventListener("click", () => { + // Abre el enlace en una nueva pestaña + window.open("https://form.jotform.com/250134957677669", "_blank"); + + // Actualiza la pestaña actual + window.location.href = window.location.href; // Redirige a la misma URL actual + }); +} + + +// Fin de la rodada +ruleta.addEventListener("transitionend", function () { + puntero.style.pointerEvents = "auto"; // Reactivar clics + ruleta.style.transition = "none"; + giraDos = gira % 360; + ruleta.style.transform = `rotate(${giraDos}deg)`; + + mostrarConfetiConTexto(articuloGanador); // Mostrar confeti + mostrarMensajeAgradecimiento(); // Mostrar mensaje de agradecimiento +}); + diff --git a/seleccionar_premio.php b/seleccionar_premio.php new file mode 100644 index 0000000..aad83a0 --- /dev/null +++ b/seleccionar_premio.php @@ -0,0 +1,128 @@ +connect_error) { + die(json_encode(["error" => "Conexión fallida: " . $conn->connect_error])); +} + +// Obtener la IP del cliente en el servidor +$ip_address = $_SERVER['REMOTE_ADDR']; + +// Recibir los datos enviados desde el frontend +$data = json_decode(file_get_contents("php://input"), true); +$user_agent = $data['user_agent'] ?? null; +$nombre_usuario = $data['nombre_usuario'] ?? null; + +// Consultar la cantidad de giros disponibles en la tabla limite_giros +$sqlLimite = "SELECT cantidad FROM girospermitidos WHERE id = 1"; // Se asume un único registro de control +$resultLimite = $conn->query($sqlLimite); + +if ($resultLimite->num_rows > 0) { + $rowLimite = $resultLimite->fetch_assoc(); + $cantidadGirosDisponibles = $rowLimite['cantidad']; + + if ($cantidadGirosDisponibles <= 0) { + echo json_encode(["error" => "Se ha alcanzado el límite de giros"]); + $conn->close(); + exit(); + } + + // Restar un giro al contador global + $stmtGiros = $conn->prepare("UPDATE girospermitidos SET cantidad = cantidad - 1 WHERE id = 1"); + if ($stmtGiros) { + $stmtGiros->execute(); + $stmtGiros->close(); + } else { + echo json_encode(["error" => "Error al actualizar el límite de giros"]); + $conn->close(); + exit(); + } +} else { + echo json_encode(["error" => "No se encontró el registro de límite de giros"]); + $conn->close(); + exit(); +} + +// Consultar los premios con cantidad mayor que 0 +$sql = "SELECT id, nombre, probabilidad, cantidad, inicio, final FROM premiosnew WHERE cantidad > 0 AND nombre_usuario = '$nombre_usuario'"; +$result = $conn->query($sql); + +$premios = array(); +$totalProbabilidad = 0; + +if ($result->num_rows > 0) { + // Calcular el total de probabilidades + while ($row = $result->fetch_assoc()) { + $premios[] = $row; + $totalProbabilidad += $row['probabilidad']; + } + + // Generar un número aleatorio entre 0 y el total de probabilidades + $random = mt_rand(0, $totalProbabilidad * 1000) / 1000; // Se multiplica por 1000 para trabajar con 3 decimales + $suma = 0; + + // Seleccionar el premio basado en la probabilidad acumulada + foreach ($premios as $premio) { + $suma += $premio['probabilidad']; + if ($random <= $suma) { + // Actualizar la cantidad del premio seleccionado + $stmt = $conn->prepare("UPDATE premiosnew SET cantidad = cantidad - 1 WHERE id = ?"); + if ($stmt) { + $stmt->bind_param("i", $premio['id']); + $stmt->execute(); + $stmt->close(); + + // Agregar el premio y nombre a la tabla premios + $stmtInsert = $conn->prepare( + "INSERT INTO premiosentregados (nombre_usuario, premio, ip_address, user_agent) + VALUES (?, ?, ?, ?)" + ); + if ($stmtInsert) { + $stmtInsert->bind_param( + "ssss", + $nombre_usuario, + $premio['nombre'], + $ip_address, + $user_agent + ); + $stmtInsert->execute(); + $stmtInsert->close(); + } else { + echo json_encode(["error" => "Error al guardar el participante"]); + exit(); + } + + // Devolver el premio seleccionado con sus rangos de ángulo + echo json_encode([ + 'id' => $premio['id'], + 'nombre' => $premio['nombre'], + 'inicio' => $premio['inicio'], + 'final' => $premio['final'] + ]); + exit(); // Salir inmediatamente después de devolver el premio seleccionado + } else { + echo json_encode(["error" => "Error en la preparación de la actualización"]); + } + break; + } + } +} else { + echo json_encode(["error" => "No hay premios disponibles"]); +} + +$conn->close(); +?> diff --git a/style.css b/style.css new file mode 100644 index 0000000..0279edd --- /dev/null +++ b/style.css @@ -0,0 +1,282 @@ +/* Estilos generales existentes */ +body, html { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: #C10F1A url(img/background.png) repeat center center; /* Imagen centrada y no repetida */ + background-size: cover; /* Hace que la imagen se ajuste al contenedor sin recortarse */ + display: flex; + justify-content: center; + align-items: center; + font-family: 'Montserrat', sans-serif; + overflow: hidden; /* Evitar desplazamiento */ +} + +.content { + text-align: center; + font-weight: bold; + font-size: 48px; + color: #ffffff; + text-shadow: + 4px 4px 0 #C10F1A; +} + +.game { + position: relative; + display: inline-block; + margin-left: -400px; +} + +#ruleta { + max-width: 100%; + height: auto; +} + +#puntero { + position: absolute; + top: 47%; + left: 50%; + width: 300px; + transform: translate(-50%, -50%); +} + +/* Estilos para dispositivos con pantallas más pequeñas */ +@media (max-width: 1024px) { + #ruleta { + width: 70%; + } + + #puntero { + width: 200px; + } +} + +@media (max-width: 768px) { + #ruleta, #puntero { + width: 50%; + } +} + +@media (max-width: 480px) { + #ruleta, #puntero { + width: 80%; + } + + #puntero { + width: 100px; + } +} + +/* Nueva clase para manejar orientación vertical */ +.landscape-message { + display: none; + text-align: center; + font-size: 24px; + color: red; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: #ffffff; + padding: 20px; + border: 2px solid red; + z-index: 9999; +} + +@media (orientation: portrait) { + .landscape-message { + display: block !important; + z-index: 100000; + } + + #ruleta, #puntero, .content, .game { + display: none !important; + } +} + +.fullscreen-btn { + position: absolute; + top: 5%; + right: 3%; + width: 40px; + height: 40px; + background: url('img/fullscreen-icon.png') no-repeat center center; + background-size: contain; + cursor: pointer; + z-index: 1000; +} + +@media (max-width: 1024px) { + .fullscreen-btn { + top: 3%; + right: 5%; + width: 35px; + height: 35px; + } +} + +@media (max-width: 768px) { + .fullscreen-btn { + top: 3%; + right: 5%; + width: 30px; + height: 30px; + } +} + +@media (max-width: 480px) { + .fullscreen-btn { + top: 2%; + right: 8%; + width: 25px; + height: 25px; + } +} + +canvas { + position: fixed !important; + top: 0; + left: 0; + width: 100% !important; + height: 100% !important; + z-index: 99999 !important; /* Mayor que cualquier otro elemento */ + pointer-events: none; /* No bloquear interacciones */ +} + +/* Estilos de los banners */ +.banner { + position: fixed; + background-size: contain; + background-repeat: no-repeat; + z-index: 1000; +} + +.banner-1 { + top: 0%; + left: 5%; + width: 150px; + height: 150px; + background-image: url('img/BANNER-1.png'); +} + +.banner-4 { + bottom: 0%; + right: 5%; + width: 400px; + height: 100px; + background-image: url('img/BANNER-4.png'); +} + +.banner-2 { + top: 10%; + right: 5%; + width: 400px; + height: 400px; + background-image: url('img/BANNER-2.png'); +} + +.popup-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.8); + justify-content: center; + align-items: center; + z-index: 1000; +} + +.popup-content { + background: #C10F1A; + padding: 20px; + border-radius: 8px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + max-width: 400px; + width: 90%; + text-align: center; + animation: fadeIn 0.3s ease-out; +} + +.popup-content h2 { + margin-bottom: 15px; + font-size: 24px; + color: #ccc; +} + +#user-form label { + display: block; + margin-bottom: 8px; + font-weight: bold; + color: #ccc; +} + +#user-form input, #user-form select, #user-form button { + width: 50%; + padding: 10px; + margin-bottom: 15px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 16px; + font-family: 'Montserrat', sans-serif; +} + +#user-form button { + background-color: #007BFF; + color: white; + font-weight: bold; + cursor: pointer; +} + +#user-form button:hover { + background-color: #0056b3; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + + +/*PopUp Agradecimiento*/ +#thank-you-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.8); + justify-content: center; + align-items: center; + z-index: 1000; + color: #ffffff; +} + +#thank-you-modal .popup-content { + background: #C10F1A; + padding: 20px; + border-radius: 8px; + text-align: center; +} + +#redirect-button { + padding: 10px 20px; + background-color: #007bff; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; +} + +#redirect-button:hover { + background-color: #0056b3; +}