initial project
42
.claude/skills/frontend-design/SKILL.md
Normal 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
@ -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
@ -0,0 +1 @@
|
||||
/cache
|
||||
1
.serena/memories/code_style_and_conventions.md
Normal 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.
|
||||
1
.serena/memories/project_overview.md
Normal 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.
|
||||
5
.serena/memories/suggested_commands.md
Normal 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.
|
||||
1
.serena/memories/task_completion_checklist.md
Normal 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
@ -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
@ -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
@ -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
@ -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_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
index.html
Normal 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
@ -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
@ -0,0 +1,3 @@
|
||||
{
|
||||
"maxNumber": 100
|
||||
}
|
||||
59
legacy/index.html
Normal 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>
|
||||
BIN
legacy/public/asset/img/background.jpg
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
244
legacy/style.css
Normal 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
|
After Width: | Height: | Size: 864 KiB |
3842
package-lock.json
generated
Normal file
32
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/asset/img/Font.png
Normal file
|
After Width: | Height: | Size: 432 KiB |
BIN
public/asset/img/background.jpg
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
1
public/vite.svg
Normal 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
|
After Width: | Height: | Size: 5.9 MiB |
42
src/App.css
Normal 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
@ -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
@ -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 |
173
src/components/HistoryPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
src/components/SlotMachine.jsx
Normal 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>
|
||||
);
|
||||
228
src/components/WinnerModal.jsx
Normal 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
@ -0,0 +1,3 @@
|
||||
{
|
||||
"maxNumber": 100
|
||||
}
|
||||
115
src/hooks/useLuckyDraw.js
Normal 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
@ -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
@ -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
@ -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: [],
|
||||
}
|
||||
7
vite.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||