initial project

This commit is contained in:
arthur 2025-12-12 09:15:19 +07:00
commit b41ea1da07
40 changed files with 5699 additions and 0 deletions

View File

@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
.serena/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/cache

View File

@ -0,0 +1 @@
Codebase uses modern React functional components with hooks (useState/useEffect) and JavaScript modules (no TypeScript). Styling is primarily TailwindCSS utility classes plus occasional inline style objects for bespoke effects. Components live in PascalCase files, hooks under `src/hooks` are camelCase and prefixed with `use`. Logic favors readable helper functions rather than large inline blocks, and comments are added sparingly for non-obvious behavior. Animations rely on Framer Motion controls so keep state-driven transitions pure and avoid direct DOM manipulation.

View File

@ -0,0 +1 @@
Lucky Draw is a React + Vite single-page app that simulates a golden-ticket style raffle machine. It renders a slot-machine inspired interface that draws unique winners from a configurable pool, persists state to localStorage, and displays history/resets. The frontend is built with React 19 + hooks, TailwindCSS for styling, and Framer Motion for reel animation along with lucide-react icons. Source lives under `src/` with `App.jsx` orchestrating the UI, reusable UI pieces in `src/components/`, business logic in `src/hooks/` (e.g., `useLuckyDraw`), JSON config/assets under `src/assets` and `public/` for static imagery. Build tooling is standard Vite + ESLint + Tailwind configuration files at the repo root.

View File

@ -0,0 +1,5 @@
- `npm install` install dependencies.
- `npm run dev` start the Vite dev server with HMR.
- `npm run build` create a production build into `dist/`.
- `npm run preview` serve the production build locally via Vite.
- `npm run lint` run ESLint across the project.

View File

@ -0,0 +1 @@
Before handing work back, run `npm run lint` (and `npm run build` when touching build-critical code) to ensure no syntax/style regressions. For UI interactions, manually verify the draw + animation flow in the Vite dev server (`npm run dev`) because automated tests are absent. Summarize any unrun commands and remaining risks in the final response.

84
.serena/project.yml Normal file
View File

@ -0,0 +1,84 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran go
# haskell java julia kotlin lua markdown
# nix perl php python python_jedi r
# rego ruby ruby_solargraph rust scala swift
# terraform typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "lucky-draw"
included_optional_tools: []

126
CLAUDE.md Normal file
View File

@ -0,0 +1,126 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a React-based lucky draw application (Year End Party draw system) built with Vite, featuring an animated slot machine interface for random number selection with persistent state management.
## Development Commands
```bash
# Install dependencies
npm install
# Start development server (with HMR)
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Lint code
npm run lint
```
## Tech Stack
- **Frontend Framework**: React 19.2.0 with JSX
- **Build Tool**: Vite 7.2.4
- **Styling**: Tailwind CSS 3.4.17 with custom configuration
- **Animation**: Framer Motion 12.23.26 (for slot machine animations)
- **Icons**: Lucide React 0.559.0
- **Linting**: ESLint 9.39.1 with flat config
## Architecture
### State Management Pattern
The app uses a custom hook pattern for state management (`useLuckyDraw` hook) that encapsulates all draw logic and persists state to localStorage. Key features:
- **Persistent State**: All draw state (pool, history, maxNumber) is automatically saved to localStorage
- **State Shape**: `{ maxNumber, remainingNumbers, drawHistory }`
- **Pool Management**: Numbers are removed from pool when drawn, preventing duplicates
- **History Tracking**: All drawn numbers are stored in reverse chronological order
### Component Structure
```
App.jsx (Main container)
├── SlotMachine.jsx (Animated 3-digit display)
│ └── Uses framer-motion with 3 independent reel controls
│ └── Staggered animation (3s, 3.5s, 4s) for luxury effect
└── HistoryPanel.jsx (Sidebar with history + controls)
└── Shows draw history, max number control, reset button
```
### Animation System (SlotMachine)
The slot machine uses a sophisticated animation approach:
- **3 Independent Reels**: Each digit has its own animation control
- **Duplicate Strips**: Each reel contains 6 sets of 0-9 (NUM_DUPLICATES=5 + final set)
- **Staggered Timing**: Reels stop at different times (delay: 0s, 0.5s, 1.0s) for dramatic effect
- **Target Calculation**: `targetY = -((NUM_DUPLICATES * 10) + digit) * SLOT_HEIGHT`
- **Animation Callback**: `onAnimationComplete` fires when all reels finish, triggering history update
### Custom Hook: useLuckyDraw
Located in `src/hooks/useLuckyDraw.js`, this hook manages:
- **Draw Logic**: Random selection from remaining pool
- **Pool Management**: Automatic pool updates and persistence
- **Animation State**: `isSpinning` flag prevents concurrent draws
- **Two-Phase Draw**: `drawNumber()` initiates, `completeDraw(winner)` finalizes after animation
### Styling Approach
- **Background**: Full-screen image (`/asset/img/background.jpg`) with overlay gradients
- **Glass Morphism**: History panel uses `backdrop-blur-xl` and `bg-white/20`
- **Custom Colors**: Tailwind extended with `sky-light`, `sky-dark`, `gold`
- **Responsive**: Main slot machine centered, history panel shows only on `xl:` breakpoint
## Important Implementation Details
### Draw Flow Sequence
1. User clicks "DRAW" button (disabled if `isSpinning` or `pool.length === 0`)
2. `drawNumber()` → Selects random number, removes from pool, sets `currentWinner`, sets `isSpinning=true`
3. `SlotMachine` detects winner change → Triggers 3-reel animation (3-4 second duration)
4. Animation completes → Calls `handleAnimationComplete()`
5. `completeDraw(currentWinner)` → Adds to history, sets `isSpinning=false`
### Max Number Changes
Changing max number triggers a confirmation dialog and full reset:
- Clears pool and regenerates with new max
- Clears history
- User must confirm to prevent accidental data loss
### Legacy Code
The `legacy/` directory contains an older vanilla JS implementation that is not part of the current app. It can be ignored for development purposes but is preserved for reference.
## File Locations
- **Config**: `src/config.json` (currently stores default maxNumber, though runtime state overrides this)
- **Background Image**: Expected at `public/asset/img/background.jpg`
- **Main Entry**: `src/main.jsx` → renders `App.jsx`
- **Styles**: `src/index.css` (imports Tailwind)
## ESLint Configuration
Uses flat config format (`eslint.config.js`) with:
- React Hooks plugin (enforces rules of hooks)
- React Refresh plugin (for HMR)
- Custom rule: Allows unused variables starting with uppercase (e.g., component imports)
## Notes for Development
- The app is NOT a git repository (no version control initialized)
- No TypeScript - uses plain JSX
- LocalStorage key: `'luckyDrawState'`
- Slot machine dimensions: Each reel is 320px (w-80 h-80), digit height is also 320px
- History panel is hidden on screens smaller than `xl` breakpoint

16
README.md Normal file
View File

@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
eslint.config.js Normal file
View File

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

BIN
image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>lucky-draw</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

231
legacy/app.js Normal file
View File

@ -0,0 +1,231 @@
document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const drawBtn = document.getElementById('draw-btn');
const resetBtn = document.getElementById('reset-btn');
const statusMsg = document.getElementById('status-message');
const historyList = document.getElementById('history-list');
const slots = [
document.getElementById('slot-1'),
document.getElementById('slot-2'),
document.getElementById('slot-3')
];
// Configuration
const SLOT_HEIGHT = 160; // px, must match CSS
const NUM_DUPLICATES = 3; // How many set of 0-9 to stack for spinning illusion
// State
let config = { maxNumber: 100 };
let appState = {
remainingNumbers: [],
drawHistory: []
};
// Initialize
async function init() {
try {
const response = await fetch('config.json');
const data = await response.json();
config = { ...config, ...data };
} catch (e) {
console.warn('Could not load config.json, using defaults.');
}
initializeSlotStrips();
loadState();
// If no state exists (first run), initialize numbers
if (appState.remainingNumbers.length === 0 && appState.drawHistory.length === 0) {
resetPool();
}
// Sync input
document.getElementById('max-number-input').value = config.maxNumber;
renderHistory();
updateUI();
drawBtn.addEventListener('click', handleDraw);
resetBtn.addEventListener('click', confirmReset);
}
function initializeSlotStrips() {
slots.forEach(slot => {
const strip = slot.querySelector('.digit-strip');
strip.innerHTML = '';
// Create a long strip: 0-9 repeated NUM_DUPLICATES times + final set for landing
let html = '';
for (let i = 0; i < NUM_DUPLICATES; i++) {
for (let d = 0; d <= 9; d++) {
html += `<div class="digit-box">${d}</div>`;
}
}
// Add one final set of 0-9 for the final target
for (let d = 0; d <= 9; d++) {
html += `<div class="digit-box">${d}</div>`;
}
strip.innerHTML = html;
// Set initial position to '0'
strip.style.transform = `translateY(0px)`;
});
}
// Core Logic
function resetPool() {
appState.remainingNumbers = Array.from({ length: config.maxNumber }, (_, i) => i + 1);
appState.drawHistory = [];
saveState();
renderHistory();
updateUI();
statusMsg.textContent = 'Ready to draw!';
// Reset slots visually to 000
slots.forEach(slot => {
const strip = slot.querySelector('.digit-strip');
strip.style.transition = 'none';
strip.style.transform = `translateY(0px)`;
strip.classList.remove('spinning');
});
}
async function handleDraw() {
if (appState.remainingNumbers.length === 0) {
statusMsg.textContent = 'No numbers remaining!';
return;
}
drawBtn.disabled = true;
statusMsg.textContent = 'Spinning...';
// 1. Pick winner
const randomIndex = Math.floor(Math.random() * appState.remainingNumbers.length);
const winner = appState.remainingNumbers[randomIndex];
// 2. Remove from pool
appState.remainingNumbers.splice(randomIndex, 1);
// 3. Add to history
appState.drawHistory.unshift(winner);
saveState(); // Save immediately in case of refresh
// 4. Animate
await animateSlots(winner);
// 5. Finalize
renderHistory();
updateUI();
drawBtn.disabled = false;
statusMsg.textContent = `Winner: ${winner}`;
}
function animateSlots(winnerNumber) {
return new Promise((resolve) => {
const strNum = winnerNumber.toString().padStart(3, '0');
const targetDigits = strNum.split('').map(Number);
slots.forEach((slot, index) => {
const strip = slot.querySelector('.digit-strip');
const targetDigit = targetDigits[index];
// Remove transition to reset position instantly if needed
// For a simple effect, we just assume we start from 0 or current position?
// To simplify: we reset to 0 (top) without transition, then spin down to target
strip.style.transition = 'none';
strip.style.transform = 'translateY(0px)';
// Force reflow
strip.offsetHeight;
// Calculate target Y
// We want to land on the LAST occurrence of this digit in our strip to mimic a long spin
// The strip has (NUM_DUPLICATES * 10) items before the final set.
// The final set starts at index (NUM_DUPLICATES * 10).
// Target index = (NUM_DUPLICATES * 10) + targetDigit
const targetIndex = (NUM_DUPLICATES * 10) + targetDigit;
const translateY = -(targetIndex * SLOT_HEIGHT);
// Add staggered delay for each slot (e.g., 0ms, 500ms, 1000ms)
const delay = index * 500;
setTimeout(() => {
strip.style.transition = `transform ${2 + (index * 0.5)}s cubic-bezier(0.1, 0.7, 0.1, 1)`;
strip.style.transform = `translateY(${translateY}px)`;
}, delay);
});
// Resolve after the last slot finishes (+ buffer)
const totalDuration = (2 * 1000) + (2 * 500) + 1000; // rough estimate
setTimeout(resolve, totalDuration);
});
}
function renderHistory() {
historyList.innerHTML = '';
appState.drawHistory.forEach((num) => {
const li = document.createElement('li');
li.textContent = num.toString().padStart(3, '0');
historyList.appendChild(li);
});
}
function updateUI() {
// Toggle disable state based on pool
if (appState.remainingNumbers.length === 0) {
drawBtn.disabled = true;
statusMsg.textContent = 'All numbers drawn!';
} else {
drawBtn.disabled = false;
}
}
// Persistence
function saveState() {
const stateToSave = {
...appState,
config: config // Save config too so maxNumber persists
};
localStorage.setItem('luckyDrawState', JSON.stringify(stateToSave));
}
function loadState() {
const saved = localStorage.getItem('luckyDrawState');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.config) {
config = parsed.config;
}
appState = {
remainingNumbers: parsed.remainingNumbers || [],
drawHistory: parsed.drawHistory || []
};
} catch (e) {
console.error('Failed to parse saved state');
}
}
}
function confirmReset() {
const newMax = parseInt(document.getElementById('max-number-input').value, 10);
if (newMax && newMax !== config.maxNumber) {
if (confirm(`Change max number to ${newMax} and reset pool?`)) {
config.maxNumber = newMax;
resetPool();
} else {
// Revert input interaction if cancelled (optional, but good UX)
document.getElementById('max-number-input').value = config.maxNumber;
}
} else {
if (confirm('Are you sure you want to reset the pool and history?')) {
resetPool();
}
}
}
// Add input listener to warn/reset on change
document.getElementById('max-number-input').addEventListener('change', confirmReset);
init();
});

3
legacy/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"maxNumber": 100
}

59
legacy/index.html Normal file
View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Year End Party - Lucky Draw</title>
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="main-header">
<h1 class="event-title">Year End Party</h1>
<h2 class="company-name">Daoukiwoom Innovation</h2>
</header>
<!-- Main Content -->
<main class="draw-area">
<div class="slot-machine">
<div class="slot-box" id="slot-1">
<div class="digit-strip">0 1 2 3 4 5 6 7 8 9 0</div>
</div>
<div class="slot-box" id="slot-2">
<div class="digit-strip">0 1 2 3 4 5 6 7 8 9 0</div>
</div>
<div class="slot-box" id="slot-3">
<div class="digit-strip">0 1 2 3 4 5 6 7 8 9 0</div>
</div>
</div>
<button id="draw-btn" class="draw-button">DRAW</button>
<div id="status-message" class="status-message"></div>
</main>
<!-- History Panel -->
<aside class="history-panel">
<h3>History</h3>
<ul id="history-list">
<!-- History items will be populated here -->
</ul>
<div class="history-controls">
<div class="control-group">
<label for="max-number-input">Max Number:</label>
<input type="number" id="max-number-input" min="1" value="100">
</div>
<button id="reset-btn" class="small-btn">Reset Pool</button>
</div>
</aside>
</div>
<script src="app.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

244
legacy/style.css Normal file
View File

@ -0,0 +1,244 @@
:root {
--primary-bg: #87CEEB;
/* Sky blue base */
--gradient-top: #E0F7FA;
--gradient-bottom: #81D4FA;
--accent-gold: #FFD700;
--text-dark: #1a1a1a;
--text-light: #ffffff;
--slot-bg: #ffffff;
--glass-bg: rgba(255, 255, 255, 0.2);
--glass-border: rgba(255, 255, 255, 0.5);
--shadow-soft: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
}
body {
margin: 0;
padding: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(135deg, var(--gradient-top) 0%, var(--gradient-bottom) 100%);
min-height: 100vh;
color: var(--text-dark);
overflow: hidden;
/* Prevent scroll during animations if possible */
}
.app-container {
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
position: relative;
}
/* Header */
.main-header {
text-align: center;
margin-top: 4vh;
margin-bottom: 2vh;
z-index: 10;
}
.event-title {
font-size: 4rem;
font-weight: 900;
color: var(--text-light);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
margin: 0;
letter-spacing: -1px;
}
.company-name {
font-size: 1.5rem;
font-weight: 400;
color: rgba(255, 255, 255, 0.9);
margin: 0.5rem 0 0 0;
letter-spacing: 1px;
}
/* Draw Area */
.draw-area {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.slot-machine {
display: flex;
gap: 20px;
padding: 20px;
background: var(--glass-bg);
border-radius: 20px;
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(8px);
margin-bottom: 40px;
}
.slot-box {
width: 120px;
height: 160px;
background: var(--slot-bg);
border-radius: 10px;
border: 4px solid #fff;
box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.1), 0 10px 20px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
font-size: 8rem;
font-weight: bold;
color: var(--text-dark);
display: flex;
align-items: center;
justify-content: center;
}
/* Digit Strip for Animation */
.digit-strip {
display: flex;
flex-direction: column;
align-items: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
/* No transition by default to allow instant reset for infinite loop effect */
will-change: transform;
}
.digit-box {
width: 100%;
/* Height must be exactly match parent .slot-box height */
height: 160px;
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
}
/* Add a class when we want to animate smoothly */
.spinning {
transition: transform 3s cubic-bezier(0.1, 0.7, 0.1, 1);
}
.draw-button {
padding: 15px 60px;
font-size: 2rem;
font-weight: bold;
color: var(--text-dark);
background: var(--accent-gold);
border: none;
border-radius: 50px;
cursor: pointer;
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.4);
transition: transform 0.2s, box-shadow 0.2s;
text-transform: uppercase;
}
.draw-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 215, 0, 0.6);
}
.draw-button:active {
transform: translateY(1px);
}
.draw-button:disabled {
background: #ccc;
cursor: not-allowed;
box-shadow: none;
}
/* History Panel */
.history-panel {
position: absolute;
right: 30px;
top: 50%;
transform: translateY(-50%);
width: 250px;
max-height: 70vh;
background: rgba(255, 255, 255, 0.8);
border-radius: 15px;
padding: 20px;
box-shadow: var(--shadow-soft);
display: flex;
flex-direction: column;
backdrop-filter: blur(10px);
}
.history-panel h3 {
margin-top: 0;
border-bottom: 2px solid #eee;
padding-bottom: 10px;
text-align: center;
}
#history-list {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
flex: 1;
}
#history-list li {
padding: 10px;
border-bottom: 1px solid #f0f0f0;
text-align: center;
font-size: 1.2rem;
font-weight: bold;
}
#history-list li:first-child {
background: rgba(255, 215, 0, 0.2);
border-radius: 5px;
}
.history-controls {
margin-top: 20px;
text-align: center;
border-top: 1px solid #eee;
padding-top: 20px;
}
.control-group {
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 0.9rem;
color: #555;
}
#max-number-input {
width: 60px;
padding: 5px;
border: 1px solid #ccc;
border-radius: 4px;
text-align: center;
}
.small-btn {
background: transparent;
border: 1px solid #999;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
color: #666;
}
.small-btn:hover {
background: #eee;
}
@media (max-width: 768px) {
.history-panel {
display: none;
/* Hide history on small screens or make it a drawer */
}
}

BIN
number.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 KiB

3842
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "lucky-draw",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^12.23.26",
"lucide-react": "^0.559.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"vite": "^7.2.4"
}
}

BIN
page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/asset/img/Font.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
reality.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

42
src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

135
src/App.jsx Normal file
View File

@ -0,0 +1,135 @@
import { useState } from 'react';
import { useLuckyDraw } from './hooks/useLuckyDraw';
import { SlotMachine } from './components/SlotMachine';
import { HistoryPanel } from './components/HistoryPanel';
import { WinnerModal } from './components/WinnerModal';
import { Sparkles } from 'lucide-react';
function App() {
const {
maxNumber,
setMaxNumber,
pool,
history,
isSpinning,
currentWinner,
drawNumber,
completeDraw,
resetPool,
showModal,
confirmWinner
} = useLuckyDraw();
const handleDraw = () => {
drawNumber();
};
const handleAnimationComplete = () => {
if (currentWinner !== null) {
completeDraw(currentWinner);
}
};
const handleReset = () => {
if (window.confirm('Are you sure you want to reset everything?')) {
resetPool(maxNumber);
}
};
const handleMaxChange = (val) => {
// Basic confirmation if pool is in use could be added here,
// but for now we just update config for next reset or immediately?
// The hook updates local state.
// If logic requires reset when max changes, we should safeguard.
// For now, let's just set it. resetPool(val) might be better UX if user INTENDS to change range.
// We'll leave it as setting the "next" max or current max state.
// If we want to change immediately:
if (val !== maxNumber) {
if (window.confirm(`Change max number to ${val} and reset?`)) {
resetPool(val);
}
}
};
return (
<div className="relative min-h-screen w-full overflow-hidden font-sans text-slate-900">
{/* Background Image - Fixed to fit all screen sizes */}
<div
className="fixed inset-0 w-full h-full z-0"
style={{
backgroundImage: `url('/asset/img/background.jpg')`,
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
backgroundAttachment: 'fixed'
}}
></div>
{/* Dark overlay for better contrast */}
<div className="fixed inset-0 w-full h-full z-0 bg-black/20"></div>
{/* Content Container */}
<div className="relative z-10 flex flex-col min-h-screen">
{/* Main Center Area */}
<div className="flex-1 flex flex-col items-center justify-center p-4 md:p-6 lg:p-8 py-8">
{/* Golden Ticket + Slot Machine - Layered */}
<div className="relative flex flex-col items-center">
{/* Golden Ticket Title - Background Layer */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 z-0 w-[200%] max-w-none">
<img
src="/asset/img/Font.png"
alt="Golden Ticket"
className="w-full h-auto"
style={{
filter: 'drop-shadow(0 0 40px rgba(255, 215, 0, 0.8)) drop-shadow(0 8px 25px rgba(212, 175, 55, 0.6))'
}}
/>
</div>
{/* Slot Machine - Foreground Layer */}
<div className="relative z-10 mt-64 sm:mt-72 md:mt-80 lg:mt-96 xl:mt-[28rem]">
<SlotMachine
winner={currentWinner}
isSpinning={isSpinning}
onAnimationComplete={handleAnimationComplete}
onDraw={handleDraw}
poolLength={pool.length}
/>
</div>
</div>
{/* No numbers remaining message */}
{pool.length === 0 && (
<div className="relative z-30 flex flex-col items-center mt-8">
<div className="text-yellow-200 bg-gradient-to-r from-yellow-900/60 to-orange-900/60 px-6 py-3 rounded-2xl backdrop-blur-sm border-2 border-yellow-600/50 font-bold shadow-xl">
No numbers remaining!
</div>
</div>
)}
{/* Winners Panel - Below Draw Button */}
<div className="w-full max-w-6xl px-4 mt-12 md:mt-16">
<HistoryPanel
history={history}
maxNumber={maxNumber}
onMaxNumberChange={handleMaxChange}
onReset={handleReset}
/>
</div>
</div>
</div>
{/* Winner Modal */}
<WinnerModal
isOpen={showModal}
winner={currentWinner}
onConfirm={confirmWinner}
/>
</div>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,173 @@
import { RefreshCw } from 'lucide-react';
import { useState } from 'react';
export function HistoryPanel({ history, onReset, maxNumber, onMaxNumberChange }) {
// Reverse to show newest first (#1 at top)
const reversedHistory = [...history].reverse();
// Local state for input value
const [inputValue, setInputValue] = useState(maxNumber);
return (
<div
className="w-full backdrop-blur-xl rounded-3xl p-6 md:p-8 lg:p-10 shadow-2xl relative overflow-hidden"
style={{
background: 'linear-gradient(135deg, rgba(0, 0, 0, 0.7) 0%, rgba(20, 20, 20, 0.5) 50%, rgba(0, 0, 0, 0.7) 100%)',
border: '3px solid transparent',
backgroundClip: 'padding-box',
boxShadow: '0 0 40px rgba(212, 175, 55, 0.4), 0 20px 60px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.1)'
}}
>
{/* Metallic border effect */}
<div className="absolute inset-0 rounded-3xl pointer-events-none" style={{
background: 'linear-gradient(135deg, #bf953f 0%, #fcf6ba 25%, #b38728 50%, #fbf5b7 75%, #aa771c 100%)',
padding: '3px',
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
}}></div>
{/* Header with Title and Controls */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8 relative z-10">
<h3
className="text-3xl md:text-4xl font-black tracking-wide flex items-center gap-3"
style={{
background: 'linear-gradient(to bottom, #bf953f 0%, #fcf6ba 40%, #b38728 55%, #fbf5b7 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
filter: 'drop-shadow(0 3px 6px rgba(212, 175, 55, 0.6))'
}}
>
🏆 Winners Board
</h3>
{/* Controls */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-3 text-white font-semibold text-base">
<label htmlFor="max-num" className="whitespace-nowrap" style={{
background: 'linear-gradient(to bottom, #bf953f 0%, #fcf6ba 50%, #b38728 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>Max:</label>
<input
id="max-num"
type="number"
min="1"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const newValue = parseInt(inputValue) || 0;
if (newValue > 0) {
onMaxNumberChange(newValue);
}
}
}}
onBlur={() => setInputValue(maxNumber)}
className="w-24 bg-white/10 border-2 border-gold-400/30 rounded-xl px-4 py-2 text-center text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-gold-400/50 focus:border-gold-400/50 backdrop-blur-sm transition-all font-bold"
/>
</div>
<button
onClick={onReset}
className="flex items-center justify-center gap-2 bg-white/10 hover:bg-gold-400/20 text-white px-5 py-2 rounded-xl transition-all duration-300 border-2 border-gold-400/30 hover:border-gold-400/50 font-semibold group text-base whitespace-nowrap hover:shadow-lg hover:shadow-gold-400/20"
>
<RefreshCw size={18} className="group-hover:rotate-180 transition-transform duration-500" />
Reset
</button>
</div>
</div>
{/* Winners Table - Vertical Rows */}
{history.length === 0 ? (
<div className="text-yellow-200/60 text-center italic py-16 text-xl relative z-10">
No winners yet - click the lottery machine to start!
</div>
) : (
<div className="space-y-4 relative z-10">
{reversedHistory.map((num, idx) => {
const rank = idx + 1;
const isTop3 = rank <= 3;
return (
<div
key={idx}
className="flex items-center gap-6 p-5 md:p-6 rounded-2xl transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl relative overflow-hidden group"
style={{
background: isTop3
? 'linear-gradient(90deg, rgba(191, 149, 63, 0.15) 0%, rgba(179, 135, 40, 0.1) 50%, rgba(170, 119, 28, 0.05) 100%)'
: 'rgba(255, 255, 255, 0.03)',
border: isTop3
? '2px solid rgba(212, 175, 55, 0.5)'
: '1px solid rgba(255, 255, 255, 0.08)',
boxShadow: isTop3
? '0 4px 20px rgba(212, 175, 55, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)'
: '0 2px 10px rgba(0, 0, 0, 0.2)',
}}
>
{/* Shimmer effect on hover */}
{isTop3 && (
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(252, 246, 186, 0.2) 50%, transparent 100%)',
backgroundSize: '200% 100%',
animation: 'shimmer 2s infinite',
}}></div>
)}
{/* Rank badge - left */}
<div className="text-2xl md:text-3xl font-black w-20 text-center flex-shrink-0" style={{
background: isTop3
? 'linear-gradient(to bottom, #bf953f 0%, #fcf6ba 40%, #b38728 55%, #fbf5b7 100%)'
: 'linear-gradient(to bottom, #9CA3AF 0%, #D1D5DB 50%, #9CA3AF 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
filter: isTop3 ? 'drop-shadow(0 2px 4px rgba(212, 175, 55, 0.5))' : 'none'
}}>
{rank === 1 && '🥇'}
{rank === 2 && '🥈'}
{rank === 3 && '🥉'}
{rank > 3 && `#${rank}`}
</div>
{/* Decorative separator */}
<div className="w-px h-16 bg-gradient-to-b from-transparent via-gold-400/30 to-transparent flex-shrink-0"></div>
{/* Number - center, LARGE with metallic effect */}
<div
className="text-5xl md:text-6xl lg:text-7xl font-black flex-1 tabular-nums"
style={{
background: 'linear-gradient(to bottom, #bf953f 0%, #fcf6ba 40%, #b38728 55%, #fbf5b7 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
filter: 'drop-shadow(0 4px 8px rgba(212, 175, 55, 0.4))',
}}
>
{num.toString().padStart(3, '0')}
</div>
</div>
);
})}
</div>
)}
{/* Show count if there are more winners */}
{history.length > 0 && (
<div className="mt-6 pt-6 border-t border-gold-400/20 text-center relative z-10">
<div className="text-base font-medium" style={{
background: 'linear-gradient(to bottom, #bf953f 0%, #fcf6ba 50%, #b38728 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
Total Winners: {history.length} Remaining: {maxNumber - history.length}
</div>
</div>
)}
<style>{`
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`}</style>
</div>
);
}

View File

@ -0,0 +1,180 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import { motion, useAnimation } from 'framer-motion';
const NUM_DUPLICATES = 5; // How many loops for the spin effect
// Fisher-Yates shuffle algorithm
const shuffleArray = (array) => {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
// Responsive slot heights based on screen size
const getSlotHeight = () => {
if (typeof window === 'undefined') return 320;
const width = window.innerWidth;
if (width < 640) return 96; // mobile (h-24)
if (width < 768) return 128; // sm (h-32)
if (width < 1024) return 176; // md (h-44)
if (width < 1280) return 224; // lg (h-56)
if (width < 1536) return 288; // xl (h-72)
return 320; // 2xl (h-80)
};
export function SlotMachine({ winner, isSpinning, onAnimationComplete, onDraw, poolLength }) {
const controls1 = useAnimation();
const controls2 = useAnimation();
const controls3 = useAnimation();
const [slotHeight, setSlotHeight] = useState(getSlotHeight());
// Generate randomized sequences for each reel (memoized so they stay consistent)
// Always start with 0 at position 0, then shuffle the rest
const randomSequences = useMemo(() => {
const createSequence = () => {
const remaining = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const shuffled = shuffleArray(remaining);
return [0, ...shuffled]; // Always put 0 first
};
return [
createSequence(), // Reel 1
createSequence(), // Reel 2
createSequence(), // Reel 3
];
}, []);
useEffect(() => {
const handleResize = () => setSlotHeight(getSlotHeight());
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
if (isSpinning && winner !== null) {
const digits = winner.toString().padStart(3, '0').split('').map(Number);
animateReels(digits);
}
}, [isSpinning, winner]);
const animateReels = async (digits) => {
// Reset positions instantly
await Promise.all([
controls1.set({ y: 0 }),
controls2.set({ y: 0 }),
controls3.set({ y: 0 })
]);
// Animate each reel with stagger
// For each digit, find its position in the randomized sequence
const spin = (control, digit, reelIndex, delay) => {
// Find where this digit appears in the last cycle of the randomized sequence
const lastCycleStart = NUM_DUPLICATES * 10;
const digitIndexInSequence = randomSequences[reelIndex].indexOf(digit);
const targetIndex = lastCycleStart + digitIndexInSequence;
const targetY = -(targetIndex * slotHeight);
return control.start({
y: targetY,
transition: {
duration: 20 + delay,
ease: [0.1, 0.9, 0.2, 1], // "Luxury" ease-out
delay: 0 // We can rely on duration difference for staggering or explicit delay
}
});
};
// Staggered finish times: 20s, 20.5s, 21s
await Promise.all([
spin(controls1, digits[0], 0, 0),
spin(controls2, digits[1], 1, 0.5),
spin(controls3, digits[2], 2, 1.0)
]);
if (onAnimationComplete) {
onAnimationComplete();
}
};
const Strip = ({ controls, reelIndex }) => {
// Generate the number strip with randomized sequence
const numbers = [];
const sequence = randomSequences[reelIndex];
// Repeat the randomized sequence NUM_DUPLICATES times, then one final cycle
for (let i = 0; i < NUM_DUPLICATES + 1; i++) {
sequence.forEach(num => numbers.push(num));
}
return (
<motion.div
className="flex flex-col items-center w-full"
animate={controls}
>
{numbers.map((num, i) => (
<div
key={i}
className="flex items-center justify-center font-black relative"
style={{
height: slotHeight,
width: '100%',
fontSize: `${slotHeight * 0.78}px`,
fontWeight: 'bold',
fontFamily: 'system-ui, -apple-system, sans-serif',
background: 'linear-gradient(to bottom, #bf953f 0%, #fcf6ba 40%, #b38728 55%, #fbf5b7 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
filter: 'drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))',
lineHeight: '1',
}}
>
{num}
</div>
))}
</motion.div>
);
};
const handleClick = () => {
if (!isSpinning && poolLength > 0 && onDraw) {
onDraw();
}
};
return (
<div
className={`relative flex items-center justify-center z-20 w-full ${
!isSpinning && poolLength > 0 ? 'cursor-pointer' : 'cursor-not-allowed'
}`}
onClick={handleClick}
>
<div className={`flex gap-2 sm:gap-3 md:gap-4 lg:gap-5 xl:gap-6 justify-center transition-transform duration-200 ${
!isSpinning && poolLength > 0 ? 'hover:scale-105 active:scale-95' : ''
}`}>
<SlotBox><Strip controls={controls1} reelIndex={0} /></SlotBox>
<SlotBox><Strip controls={controls2} reelIndex={1} /></SlotBox>
<SlotBox><Strip controls={controls3} reelIndex={2} /></SlotBox>
</div>
</div>
);
}
const SlotBox = ({ children }) => (
<div
className="relative w-32 h-40 sm:w-40 sm:h-48 md:w-48 md:h-60 lg:w-60 lg:h-[18rem] xl:w-72 xl:h-[22rem] 2xl:w-80 2xl:h-96 flex items-start justify-center overflow-hidden"
style={{
borderRadius: '1.5rem',
background: '#FFFFFF',
border: '3px solid #D4AF37',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3), 0 0 0 2px #D4AF37'
}}
>
<div className="relative w-full h-full">
{children}
</div>
</div>
);

View File

@ -0,0 +1,228 @@
import { motion, AnimatePresence } from 'framer-motion';
import { createPortal } from 'react-dom';
import { Trophy, Star } from 'lucide-react';
export function WinnerModal({ isOpen, winner, onConfirm }) {
if (!isOpen) return null;
return createPortal(
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{
background: 'rgba(0, 0, 0, 0.92)',
backdropFilter: 'blur(8px)'
}}
onClick={onConfirm}
>
{/* Floating particles animation */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{[...Array(20)].map((_, i) => (
<motion.div
key={i}
className="absolute"
initial={{
x: Math.random() * window.innerWidth,
y: window.innerHeight + 100,
opacity: 0
}}
animate={{
y: -100,
opacity: [0, 1, 0],
scale: [0, 1, 0]
}}
transition={{
duration: 3 + Math.random() * 2,
repeat: Infinity,
delay: Math.random() * 3,
ease: "easeOut"
}}
>
<Star size={20} fill="#D4AF37" color="#D4AF37" />
</motion.div>
))}
</div>
<motion.div
initial={{ scale: 0.5, opacity: 0, rotateY: -180 }}
animate={{ scale: 1, opacity: 1, rotateY: 0 }}
exit={{ scale: 0.5, opacity: 0, rotateY: 180 }}
transition={{
type: 'spring',
duration: 0.8,
bounce: 0.3
}}
className="relative max-w-2xl w-full overflow-hidden rounded-[2rem] shadow-2xl"
style={{
background: 'linear-gradient(135deg, rgba(0, 0, 0, 0.95) 0%, rgba(20, 20, 20, 0.9) 100%)',
backdropFilter: 'blur(20px)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Metallic border */}
<div className="absolute inset-0 rounded-[2rem] pointer-events-none" style={{
background: 'linear-gradient(135deg, #bf953f 0%, #fcf6ba 25%, #b38728 50%, #fbf5b7 75%, #aa771c 100%)',
padding: '4px',
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
}}></div>
{/* Glow effect */}
<div className="absolute inset-0 rounded-[2rem] pointer-events-none" style={{
boxShadow: '0 0 80px rgba(212, 175, 55, 0.5), inset 0 0 80px rgba(212, 175, 55, 0.1)'
}}></div>
<div className="relative z-10 p-8 md:p-12 lg:p-16">
{/* Animated Trophy Icon */}
<motion.div
className="flex justify-center mb-8"
animate={{
rotate: [0, -10, 10, -10, 10, 0],
scale: [1, 1.1, 1]
}}
transition={{
duration: 2,
repeat: Infinity,
repeatDelay: 1
}}
>
<div
className="relative w-28 h-28 md:w-32 md:h-32 rounded-full flex items-center justify-center"
style={{
background: 'linear-gradient(135deg, #bf953f 0%, #fcf6ba 50%, #b38728 100%)',
boxShadow: '0 8px 40px rgba(212, 175, 55, 0.6), inset 0 2px 4px rgba(255, 255, 255, 0.3)'
}}
>
<Trophy size={60} color="#FFFFFF" strokeWidth={2.5} />
{/* Sparkle effect */}
<motion.div
className="absolute top-0 right-0"
animate={{
scale: [0, 1, 0],
rotate: [0, 180, 360]
}}
transition={{
duration: 2,
repeat: Infinity,
repeatDelay: 0.5
}}
>
<Star size={24} fill="#FFE87C" color="#FFE87C" />
</motion.div>
</div>
</motion.div>
{/* Title with shimmer effect */}
<motion.h2
className="text-4xl md:text-5xl lg:text-6xl font-black text-center mb-6 relative"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.3 }}
>
<span style={{
background: 'linear-gradient(to bottom, #bf953f 0%, #fcf6ba 40%, #b38728 55%, #fbf5b7 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
filter: 'drop-shadow(0 4px 12px rgba(212, 175, 55, 0.8))',
}}>
WINNER!
</span>
</motion.h2>
{/* Subtitle */}
<motion.p
className="text-center text-white/70 text-xl md:text-2xl mb-10 font-medium"
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.4 }}
>
The winning number is
</motion.p>
{/* Winner Number with luxury card */}
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, type: 'spring' }}
className="relative mb-12 p-8 md:p-10 rounded-3xl overflow-hidden"
style={{
background: 'linear-gradient(135deg, rgba(191, 149, 63, 0.15) 0%, rgba(179, 135, 40, 0.1) 100%)',
border: '3px solid rgba(212, 175, 55, 0.5)',
boxShadow: '0 8px 40px rgba(212, 175, 55, 0.3), inset 0 2px 4px rgba(255, 255, 255, 0.1)'
}}
>
{/* Animated shimmer overlay */}
<motion.div
className="absolute inset-0 pointer-events-none"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(252, 246, 186, 0.3) 50%, transparent 100%)',
}}
animate={{
x: ['-100%', '200%']
}}
transition={{
duration: 3,
repeat: Infinity,
ease: 'linear'
}}
></motion.div>
<div
className="text-center text-8xl md:text-9xl font-black tabular-nums relative z-10"
style={{
background: 'linear-gradient(to bottom, #bf953f 0%, #fcf6ba 40%, #b38728 55%, #fbf5b7 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
filter: 'drop-shadow(0 6px 16px rgba(212, 175, 55, 0.5))',
letterSpacing: '0.1em'
}}
>
{winner !== null ? winner.toString().padStart(3, '0') : '000'}
</div>
</motion.div>
{/* OK Button with luxury styling */}
<motion.button
onClick={onConfirm}
className="w-full py-5 rounded-2xl text-2xl font-bold uppercase tracking-wider relative overflow-hidden group"
style={{
background: 'linear-gradient(135deg, #bf953f 0%, #fcf6ba 25%, #b38728 50%, #fbf5b7 75%, #aa771c 100%)',
boxShadow: '0 6px 30px rgba(212, 175, 55, 0.6)',
color: '#000000',
fontFamily: "'Inter', sans-serif",
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.6 }}
>
{/* Button shimmer effect */}
<motion.div
className="absolute inset-0 pointer-events-none"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.6) 50%, transparent 100%)',
}}
animate={{
x: ['-100%', '200%']
}}
transition={{
duration: 2,
repeat: Infinity,
ease: 'linear'
}}
></motion.div>
<span className="relative z-10 font-black">CONTINUE</span>
</motion.button>
</div>
</motion.div>
</motion.div>
</AnimatePresence>,
document.body
);
}

3
src/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"maxNumber": 100
}

115
src/hooks/useLuckyDraw.js Normal file
View File

@ -0,0 +1,115 @@
import { useState, useEffect, useCallback } from 'react';
import configData from '../config.json'; // We'll move or import config relative to src
const defaultConfig = { maxNumber: 180 };
export function useLuckyDraw() {
const [maxNumber, setMaxNumber] = useState(180);
const [pool, setPool] = useState([]);
const [history, setHistory] = useState([]);
const [isSpinning, setIsSpinning] = useState(false);
const [currentWinner, setCurrentWinner] = useState(null);
const [isInitialized, setIsInitialized] = useState(false);
const [showModal, setShowModal] = useState(false);
const generatePool = (max) => Array.from({ length: max }, (_, i) => i + 1);
// Initialize from localStorage or defaults
useEffect(() => {
const saved = localStorage.getItem('luckyDrawState');
if (saved) {
try {
const parsed = JSON.parse(saved);
const loadedMax = parsed.maxNumber || 180;
setMaxNumber(loadedMax);
// If pool exists and has items, use it. Otherwise generate a new one.
if (parsed.remainingNumbers && parsed.remainingNumbers.length > 0) {
setPool(parsed.remainingNumbers);
} else {
// Pool is empty or missing, generate new pool
setPool(generatePool(loadedMax));
}
setHistory(parsed.drawHistory || []);
} catch (e) {
console.error('Failed to parse saved state', e);
setPool(generatePool(180));
}
} else {
// No saved state, initialize fresh
setPool(generatePool(180));
}
setIsInitialized(true);
}, []);
// Save on change (only after initialization to avoid saving empty state)
useEffect(() => {
if (!isInitialized) return;
const stateToSave = {
maxNumber,
remainingNumbers: pool,
drawHistory: history
};
localStorage.setItem('luckyDrawState', JSON.stringify(stateToSave));
}, [pool, history, maxNumber, isInitialized]);
const resetPool = (newMax = maxNumber) => {
const p = generatePool(newMax);
setPool(p);
setHistory([]);
setMaxNumber(newMax);
setCurrentWinner(null);
};
const drawNumber = useCallback(() => {
if (pool.length === 0 || isSpinning) return null;
setIsSpinning(true);
const randomIndex = Math.floor(Math.random() * pool.length);
const winner = pool[randomIndex];
// We don't remove immediately from state to allow animation to finish logically if we wanted,
// but typically we determine winner now and update state.
// Let's update pool AFTER animation to be safe?
// Or update now. Update now is fine, we just display animation.
const newPool = [...pool];
newPool.splice(randomIndex, 1);
setPool(newPool);
// We'll return the winner to the UI to trigger animation
setCurrentWinner(winner);
// Return a promise that resolves when animation "time" is visually done?
// Or just return the winner.
return winner;
}, [pool, isSpinning]);
const completeDraw = useCallback((winner) => {
setHistory(prev => [winner, ...prev]);
setShowModal(true);
// Don't set isSpinning to false yet - wait for modal confirmation
}, []);
const confirmWinner = useCallback(() => {
setShowModal(false);
setCurrentWinner(null); // Reset to 000
setIsSpinning(false); // Allow next draw
}, []);
return {
maxNumber,
setMaxNumber,
pool,
history,
isSpinning,
currentWinner,
drawNumber,
completeDraw,
resetPool,
showModal,
confirmWinner
};
}

9
src/index.css Normal file
View File

@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply font-sans text-gray-900;
}
}

10
src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

35
tailwind.config.js Normal file
View File

@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
colors: {
'sky-light': '#E0F7FA',
'sky-dark': '#81D4FA',
gold: {
50: '#FFFEF7',
100: '#FFF9E6',
200: '#FFE87C',
300: '#F4D345',
400: '#D4AF37',
500: '#C19A2E',
600: '#A67C1B',
700: '#8B6914',
800: '#6B4E0D',
900: '#4A3508',
},
},
backgroundImage: {
'sky-gradient': 'linear-gradient(135deg, #E0F7FA 0%, #81D4FA 100%)',
'gradient-radial': 'radial-gradient(circle, var(--tw-gradient-stops))',
}
},
},
plugins: [],
}

BIN
test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

7
vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})