Compare commits

3 Commits

Author SHA1 Message Date
3db63569e6 docs: add project README with setup instructions 2026-06-10 15:57:34 +03:30
df3ccb1886 feat: implement advanced reward management and wheel customization
- Add customizable background color
- Add wheel animations and sound effects
- Add reward description field and winner modal
- Change font to Dana
- Add configurable reward probabilities
- Support reward types (Empty, Discount Code, LiveCoin)
- Replace reward title with reward type selector
- Allow wheel page title customization from admin panel
2026-06-10 15:41:08 +03:30
ec86cdc528 feat: implement advanced reward management and wheel customization
- Add customizable background color
- Add wheel animations and sound effects
- Add reward description field and winner modal
- Change font to Dana
- Add configurable reward probabilities
- Support reward types (Empty, Discount Code, LiveCoin)
- Replace reward title with reward type selector
- Allow wheel page title customization from admin panel
2026-06-10 14:55:07 +03:30
27 changed files with 1589 additions and 533 deletions

329
backend/README.md Normal file
View File

@@ -0,0 +1,329 @@
# Spin Wheel System
A web-based Lucky Wheel application developed with **FastAPI**, **JavaScript**, and **SQLite**. The system allows administrators to manage rewards, configure winning probabilities, customize the wheel appearance, and provide an engaging user experience with animations and sound effects.
---
# Features
## User Features
* Interactive spin wheel
* Smooth spinning animations
* Sound effects during wheel rotation
* Winner notification modal
* Reward description display
* Responsive user interface
* Customizable page title
* Customizable background color
## Admin Features
* Create, edit, and delete rewards
* Configure reward probabilities
* Manage reward descriptions
* Configure wheel page title
* Customize wheel appearance
* Manage reward types
* Real-time reward management
---
# Supported Reward Types
The system supports three reward categories:
| Reward Type | Description |
| ------------- | ------------------------- |
| Empty | No reward |
| Discount Code | Promotional discount code |
| LiveCoin (LC) | Virtual currency reward |
---
# Technology Stack
## Backend
* Python 3.10+
* FastAPI
* SQLAlchemy
* Alembic
* SQLite
## Frontend
* HTML5
* CSS3
* JavaScript (Vanilla JS)
* Dana Font
## Development Tools
* Git
* Uvicorn
---
# Project Structure
```text
spin-wheel/
├── backend/
│ ├── alembic/
│ ├── api/
│ │ ├── admin.py
│ │ └── user.py
│ │
│ ├── app/
│ ├── core/
│ ├── logs/
│ ├── services/
│ │
│ ├── main.py
│ ├── schemas.py
│ ├── config.py
│ ├── requirements.txt
│ └── spinwheel.db
├── frontend/
│ ├── assets/
│ │ ├── css/
│ │ └── fonts/
│ │
│ ├── js/
│ │ ├── admin.js
│ │ └── api.js
│ │
│ ├── admin.html
│ └── index.html
└── README.md
```
---
# Installation
## 1. Clone the Repository
```bash
git clone <repository-url>
cd spin-wheel
```
---
## 2. Create a Virtual Environment
### Windows
```bash
python -m venv venv
venv\Scripts\activate
```
### Linux / macOS
```bash
python3 -m venv venv
source venv/bin/activate
```
---
## 3. Install Backend Dependencies
```bash
cd backend
pip install -r requirements.txt
```
---
## 4. Configure Environment Variables
Create a `.env` file inside the backend directory if required.
Example:
```env
DATABASE_URL=sqlite:///spinwheel.db
```
---
## 5. Run Database Migrations
```bash
alembic upgrade head
```
---
## 6. Start Backend Server
```bash
uvicorn main:app --reload
```
Backend API:
```text
http://127.0.0.1:8000
```
Swagger Documentation:
```text
http://127.0.0.1:8000/docs
```
---
## 7. Start Frontend
Serve the frontend application on port 3000.
Example:
```bash
python -m http.server 3000
```
Frontend URL:
```text
http://localhost:3000/index.html
http://localhost:3000/admin.html
```
---
# API Endpoints
## User APIs
### Get Rewards
```http
GET /user/items
```
### Spin the Wheel
```http
POST /user/spin
```
Example Response:
```json
{
"id": 1,
"reward_type": "DISCOUNT_CODE",
"title": "20% Discount",
"description": "Get 20% off on your next purchase.",
"probability": 15
}
```
---
## Admin APIs
### Get Rewards
```http
GET /admin/items
```
### Create Reward
```http
POST /admin/items
```
### Update Reward
```http
PUT /admin/items/{id}
```
### Delete Reward
```http
DELETE /admin/items/{id}
```
---
# Reward Probability System
Each reward has a configurable probability value.
The winner is selected using a weighted random selection algorithm.
Example:
| Reward | Probability |
| ------------- | ----------- |
| Empty | 50 |
| Discount Code | 30 |
| LiveCoin | 20 |
Higher values increase the chance of winning that reward.
---
# Customization Options
The admin panel allows configuration of:
* Wheel page title
* Background color
* Reward descriptions
* Reward probabilities
* Reward types
* Wheel appearance
---
# User Experience Enhancements
* Wheel spinning animation
* Sound effects
* Winner modal popup
* Reward details display
* Modern Persian typography using Dana Font
---
# Future Improvements
* Authentication & Authorization
* User Accounts
* Spin History
* Analytics Dashboard
* Campaign Management
* Multi-language Support
* Docker Deployment
---
# Security Recommendations
For production environments:
* Enable HTTPS
* Protect admin endpoints
* Validate all incoming requests
* Store secrets in environment variables
* Configure proper CORS policies
---
# License
This project is intended for educational purposes and commercial lucky wheel campaigns. It can be extended and customized based on business requirements.

View File

@@ -0,0 +1,28 @@
"""update items table
Revision ID: eb35214a1411
Revises: f71dc2bbdfc6
Create Date: 2026-06-03 02:15:20.584639
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'eb35214a1411'
down_revision: Union[str, Sequence[str], None] = 'f71dc2bbdfc6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass

View File

@@ -0,0 +1,28 @@
"""update items table
Revision ID: f71dc2bbdfc6
Revises: b55808a86947
Create Date: 2026-06-03 02:12:35.370477
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f71dc2bbdfc6'
down_revision: Union[str, Sequence[str], None] = 'b55808a86947'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass

View File

@@ -1,9 +1,5 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
import schemas
from schemas import ItemResponse
from services import crud
@@ -13,7 +9,6 @@ from core.logger import get_logger
router = APIRouter()
logger = get_logger('admin')
def get_db():
db = SessionLocal()
try:
@@ -21,7 +16,6 @@ def get_db():
finally:
db.close()
# GET ALL ITEMS
@router.get("/items", response_model=list[ItemResponse])
def get_items(db: Session = Depends(get_db)):
@@ -33,28 +27,51 @@ def get_items(db: Session = Depends(get_db)):
logger.critical(f"Unexpected error fetching items: {e}")
raise
# CREATE ITEM
@router.post("/items", response_model=ItemResponse)
def create_item(
item: schemas.ItemCreate,
db: Session = Depends(get_db)
):
def create_item(item: schemas.ItemCreate, db: Session = Depends(get_db)):
try:
result = crud.create_item(db, item.title, item.value) # ✅ value اضافه شد
result = crud.create_item(
db,
item.title,
item.prize,
item.description,
item.prize_type,
item.probability
)
logger.info(f"Item created: {item.title}")
return result
except Exception as e:
logger.critical(f"Unexpected error creating item: {e}")
raise
# UPDATE ITEM
@router.put("/items/{item_id}", response_model=ItemResponse)
def update_item(item_id: int, item: schemas.ItemUpdate, db: Session = Depends(get_db)):
try:
updated = crud.update_item(
db,
item_id,
item.title,
item.prize,
item.description,
item.prize_type,
item.probability
)
if not updated:
logger.warning(f"Item not found: {item_id}")
raise HTTPException(status_code=404, detail="Item not found")
logger.info(f"Item updated: {item_id}")
return updated
except HTTPException:
raise
except Exception as e:
logger.critical(f"Unexpected error updating item: {e}")
raise
# DELETE ITEM
@router.delete("/items/{item_id}")
def delete_item(
item_id: int,
db: Session = Depends(get_db)
):
def delete_item(item_id: int, db: Session = Depends(get_db)):
try:
item = crud.delete_item(db, item_id)
if not item:
@@ -67,27 +84,3 @@ def delete_item(
except Exception as e:
logger.critical(f"Unexpected error deleting item: {e}")
raise
@router.put("/items/{item_id}", response_model=ItemResponse)
def update_item(
item_id: int,
item: schemas.ItemCreate,
db: Session = Depends(get_db)
):
try:
updated = crud.update_item(db, item_id, item.title, item.value)
if not updated:
logger.warning(f"Item not found: {item_id}")
raise HTTPException(status_code=404, detail="Item not found")
logger.info(f"Item updated: {item_id}")
return updated
except HTTPException:
raise
except Exception as e:
logger.critical(f"Unexpected error updating item: {e}")
raise

View File

@@ -51,7 +51,10 @@ def spin(db: Session = Depends(get_db)):
return {
"winner": {
"id": winner.id,
"title": winner.title
"title": winner.title,
"prize": winner.prize,
"prize_type": winner.prize_type,
"description": winner.description or ""
}
}

View File

@@ -1,26 +1,12 @@
from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Column, Integer, String, Float
from app.db.session import Base
class Item(Base):
__tablename__ = "items"
id = Column(
Integer,
primary_key=True,
index=True
)
title = Column(
String,
nullable=False
)
value = Column(
Integer,
nullable=False
)
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
prize = Column(String, nullable=False)
description = Column(String, nullable=True, default="")
prize_type = Column(String, nullable=False, default="پوچ")
probability = Column(Float, nullable=False, default=1.0)

View File

@@ -287,3 +287,231 @@ FROM items]
2026-05-30 12:50:43 | INFO | admin | Items fetched: 8 items
2026-05-30 12:51:00 | INFO | user | User fetched items: 8 items
2026-05-30 12:53:57 | INFO | user | Spin result: پوچ
2026-05-30 14:30:41 | INFO | admin | Items fetched: 8 items
2026-05-30 14:31:19 | INFO | user | User fetched items: 8 items
2026-05-30 14:31:21 | INFO | user | Spin result: تخفيف يک ماهه
2026-05-30 14:32:54 | INFO | user | Spin result: پوچ
2026-05-30 14:33:18 | INFO | admin | Item updated: 1
2026-05-30 14:33:18 | INFO | admin | Items fetched: 8 items
2026-05-30 14:33:38 | INFO | user | Spin result: تخفيف سه ماهه
2026-06-03 02:01:07 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.type
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.type AS items_type, items.description AS items_description, items.probability AS items_probability, items.value AS items_value
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-03 02:01:11 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.type
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.type AS items_type, items.description AS items_description, items.probability AS items_probability, items.value AS items_value
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-03 02:08:13 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.type
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.type AS items_type, items.description AS items_description, items.probability AS items_probability, items.value AS items_value
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-03 02:30:22 | CRITICAL | admin | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.prize
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.prize AS items_prize, items.description AS items_description, items.prize_type AS items_prize_type, items.probability AS items_probability
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-07 00:22:06 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.prize
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.prize AS items_prize, items.description AS items_description, items.prize_type AS items_prize_type, items.probability AS items_probability
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-07 00:22:08 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.prize
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.prize AS items_prize, items.description AS items_description, items.prize_type AS items_prize_type, items.probability AS items_probability
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-07 00:23:31 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.prize
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.prize AS items_prize, items.description AS items_description, items.prize_type AS items_prize_type, items.probability AS items_probability
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-07 00:24:02 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.prize
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.prize AS items_prize, items.description AS items_description, items.prize_type AS items_prize_type, items.probability AS items_probability
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-07 00:26:33 | INFO | user | User fetched items: 0 items
2026-06-07 00:32:56 | INFO | user | User fetched items: 0 items
2026-06-07 00:36:17 | INFO | user | User fetched items: 0 items
2026-06-07 00:36:21 | INFO | user | User fetched items: 0 items
2026-06-07 00:39:11 | INFO | user | User fetched items: 0 items
2026-06-07 00:39:15 | INFO | admin | Items fetched: 0 items
2026-06-07 00:39:33 | INFO | admin | Items fetched: 0 items
2026-06-07 00:49:16 | INFO | admin | Items fetched: 0 items
2026-06-07 00:49:39 | INFO | admin | Item created: jotdt 20
2026-06-07 00:49:39 | INFO | admin | Items fetched: 1 items
2026-06-07 00:49:44 | INFO | user | User fetched items: 1 items
2026-06-07 00:49:47 | INFO | user | Spin result: jotdt 20
2026-06-07 00:50:31 | INFO | admin | Item created: پوچ
2026-06-07 00:50:31 | INFO | admin | Items fetched: 2 items
2026-06-07 00:50:38 | INFO | user | User fetched items: 2 items
2026-06-07 00:50:40 | INFO | user | Spin result: پوچ
2026-06-07 00:51:12 | INFO | admin | Item created: تخفيف 20
2026-06-07 00:51:12 | INFO | admin | Items fetched: 3 items
2026-06-07 00:51:16 | INFO | user | User fetched items: 3 items
2026-06-07 00:52:18 | INFO | user | Spin result: پوچ
2026-06-07 00:53:54 | INFO | admin | Item updated: 1
2026-06-07 00:53:54 | INFO | admin | Items fetched: 3 items
2026-06-07 00:54:22 | INFO | admin | Item created: تخفيف 30
2026-06-07 00:54:22 | INFO | admin | Items fetched: 4 items
2026-06-07 00:54:35 | INFO | user | User fetched items: 4 items
2026-06-07 00:54:38 | INFO | user | Spin result: پوچ
2026-06-07 00:55:39 | INFO | user | Spin result: تخفيف 30
2026-06-07 00:56:16 | INFO | admin | Item created: تخفيف 30
2026-06-07 00:56:16 | INFO | admin | Items fetched: 5 items
2026-06-07 00:56:51 | INFO | admin | Item created: پوچ
2026-06-07 00:56:51 | INFO | admin | Items fetched: 6 items
2026-06-07 00:56:58 | INFO | user | User fetched items: 6 items
2026-06-07 00:57:01 | INFO | user | Spin result: پوچ
2026-06-07 00:57:13 | INFO | user | Spin result: پوچ
2026-06-07 01:03:09 | INFO | user | User fetched items: 6 items
2026-06-07 01:03:12 | INFO | user | Spin result: پوچ
2026-06-07 01:07:35 | INFO | admin | Items fetched: 6 items
2026-06-07 01:07:37 | INFO | user | User fetched items: 6 items
2026-06-07 01:07:39 | INFO | user | Spin result: تخفيف 20
2026-06-07 02:15:44 | INFO | user | User fetched items: 6 items
2026-06-07 02:15:45 | INFO | user | Spin result: پوچ
2026-06-07 02:19:11 | INFO | user | Spin result: پوچ
2026-06-07 02:19:40 | INFO | user | Spin result: پوچ
2026-06-07 13:34:50 | INFO | user | User fetched items: 6 items
2026-06-07 13:34:52 | INFO | user | Spin result: پوچ
2026-06-07 13:35:15 | INFO | admin | Items fetched: 6 items
2026-06-07 13:35:36 | INFO | user | Spin result: پوچ
2026-06-10 11:55:45 | INFO | user | User fetched items: 6 items
2026-06-10 11:56:01 | INFO | admin | Items fetched: 6 items
2026-06-10 11:57:45 | INFO | user | Spin result: پوچ
2026-06-10 11:59:13 | INFO | user | Spin result: پوچ
2026-06-10 11:59:33 | INFO | admin | Item updated: 3
2026-06-10 11:59:33 | INFO | admin | Items fetched: 6 items
2026-06-10 11:59:36 | INFO | user | Spin result: پوچ
2026-06-10 11:59:45 | INFO | user | Spin result: تخفيف 20
2026-06-10 12:01:01 | INFO | user | Spin result: پوچ
2026-06-10 12:02:20 | INFO | user | Spin result: پوچ
2026-06-10 12:07:31 | INFO | user | User fetched items: 6 items
2026-06-10 12:07:31 | INFO | user | User fetched items: 6 items
2026-06-10 12:07:31 | INFO | user | Spin result: تخفيف 20
2026-06-10 12:07:31 | INFO | user | User fetched items: 6 items
2026-06-10 12:07:31 | INFO | user | User fetched items: 6 items
2026-06-10 12:07:31 | INFO | user | User fetched items: 6 items
2026-06-10 12:08:44 | INFO | user | User fetched items: 6 items
2026-06-10 12:08:47 | INFO | user | Spin result: تخفيف 20
2026-06-10 12:17:29 | INFO | user | User fetched items: 6 items
2026-06-10 12:17:32 | INFO | user | Spin result: پوچ
2026-06-10 12:18:03 | INFO | user | Spin result: پوچ
2026-06-10 12:18:44 | INFO | user | Spin result: تخفيف 20
2026-06-10 12:19:16 | INFO | user | User fetched items: 6 items
2026-06-10 12:19:20 | INFO | user | Spin result: تخفيف 20
2026-06-10 12:20:37 | INFO | user | Spin result: پوچ
2026-06-10 12:20:41 | INFO | user | User fetched items: 6 items
2026-06-10 12:20:49 | INFO | admin | Items fetched: 6 items
2026-06-10 12:22:37 | INFO | user | User fetched items: 6 items
2026-06-10 12:22:39 | INFO | user | Spin result: تخفيف 20
2026-06-10 12:22:49 | INFO | user | Spin result: تخفيف 20
2026-06-10 12:23:08 | INFO | user | User fetched items: 6 items
2026-06-10 12:23:09 | INFO | user | Spin result: پوچ
2026-06-10 12:25:32 | INFO | user | User fetched items: 6 items
2026-06-10 12:25:34 | INFO | user | Spin result: تخفيف 20
2026-06-10 12:30:55 | INFO | user | User fetched items: 6 items
2026-06-10 12:30:56 | INFO | user | Spin result: پوچ
2026-06-10 12:36:33 | INFO | user | User fetched items: 6 items
2026-06-10 12:36:35 | INFO | user | Spin result: تخفيف 20
2026-06-10 12:37:57 | INFO | user | User fetched items: 6 items
2026-06-10 12:48:20 | INFO | user | User fetched items: 6 items
2026-06-10 12:51:46 | INFO | user | User fetched items: 6 items
2026-06-10 12:58:36 | INFO | user | User fetched items: 6 items
2026-06-10 12:58:41 | INFO | user | Spin result: تخفيف 30
2026-06-10 12:59:01 | INFO | admin | Items fetched: 6 items
2026-06-10 12:59:04 | INFO | admin | Items fetched: 6 items
2026-06-10 13:00:57 | INFO | user | User fetched items: 6 items
2026-06-10 13:01:44 | INFO | user | User fetched items: 6 items
2026-06-10 13:01:46 | INFO | user | User fetched items: 6 items
2026-06-10 13:07:23 | INFO | user | User fetched items: 6 items
2026-06-10 13:07:25 | INFO | user | Spin result: تخفيف 20
2026-06-10 13:07:51 | INFO | admin | Items fetched: 6 items
2026-06-10 13:09:29 | INFO | user | Spin result: تخفيف 20
2026-06-10 13:12:47 | INFO | user | User fetched items: 6 items
2026-06-10 13:14:30 | INFO | user | User fetched items: 6 items
2026-06-10 13:14:34 | INFO | user | Spin result: پوچ
2026-06-10 13:15:38 | INFO | user | User fetched items: 6 items
2026-06-10 13:15:41 | INFO | user | Spin result: تخفيف 20
2026-06-10 13:16:57 | INFO | user | User fetched items: 6 items
2026-06-10 13:18:40 | INFO | user | User fetched items: 6 items
2026-06-10 13:19:19 | INFO | user | User fetched items: 6 items
2026-06-10 13:19:21 | INFO | user | User fetched items: 6 items
2026-06-10 13:19:25 | INFO | admin | Items fetched: 6 items
2026-06-10 13:22:09 | INFO | user | User fetched items: 6 items
2026-06-10 13:22:17 | INFO | user | User fetched items: 6 items
2026-06-10 13:22:20 | INFO | admin | Items fetched: 6 items
2026-06-10 13:23:23 | INFO | user | Spin result: تخفيف 20
2026-06-10 13:24:44 | INFO | user | User fetched items: 6 items
2026-06-10 13:25:17 | INFO | user | Spin result: تخفيف 20
2026-06-10 13:27:10 | INFO | admin | Items fetched: 6 items
2026-06-10 13:28:35 | INFO | admin | Items fetched: 6 items
2026-06-10 13:29:16 | INFO | admin | Items fetched: 6 items
2026-06-10 13:29:41 | INFO | user | Spin result: تخفيف 20
2026-06-10 13:30:47 | INFO | admin | Items fetched: 6 items
2026-06-10 13:31:14 | INFO | admin | Items fetched: 6 items
2026-06-10 13:31:41 | INFO | admin | Items fetched: 6 items
2026-06-10 13:32:39 | INFO | user | User fetched items: 6 items
2026-06-10 13:33:20 | INFO | user | User fetched items: 6 items
2026-06-10 13:35:04 | INFO | user | User fetched items: 6 items
2026-06-10 13:37:57 | INFO | user | User fetched items: 6 items
2026-06-10 13:39:21 | INFO | user | User fetched items: 6 items
2026-06-10 13:39:41 | INFO | user | User fetched items: 6 items
2026-06-10 13:39:53 | INFO | user | User fetched items: 6 items
2026-06-10 13:40:34 | INFO | user | User fetched items: 6 items
2026-06-10 13:42:08 | INFO | user | User fetched items: 6 items
2026-06-10 13:42:11 | INFO | user | Spin result: تخفيف 30
2026-06-10 13:44:29 | INFO | user | Spin result: پوچ
2026-06-10 13:48:32 | INFO | user | User fetched items: 6 items
2026-06-10 13:48:38 | INFO | admin | Items fetched: 6 items
2026-06-10 13:49:14 | INFO | user | Spin result: پوچ
2026-06-10 13:49:22 | INFO | user | Spin result: پوچ
2026-06-10 13:49:32 | INFO | user | Spin result: تخفيف 20
2026-06-10 13:50:41 | INFO | admin | Item updated: 3
2026-06-10 13:50:41 | INFO | admin | Items fetched: 6 items
2026-06-10 13:51:15 | INFO | user | Spin result: تخفيف 30
2026-06-10 13:51:53 | INFO | user | Spin result: پوچ
2026-06-10 13:53:38 | INFO | user | User fetched items: 6 items
2026-06-10 13:53:58 | INFO | user | Spin result: پوچ
2026-06-10 13:55:11 | INFO | user | Spin result: پوچ
2026-06-10 13:57:25 | INFO | user | Spin result: پوچ
2026-06-10 13:58:19 | INFO | user | User fetched items: 6 items
2026-06-10 13:58:48 | INFO | user | User fetched items: 6 items
2026-06-10 14:02:41 | INFO | user | User fetched items: 6 items
2026-06-10 14:02:44 | INFO | admin | Items fetched: 6 items
2026-06-10 14:03:24 | INFO | admin | Items fetched: 6 items
2026-06-10 14:04:02 | INFO | admin | Items fetched: 6 items
2026-06-10 14:04:47 | INFO | user | Spin result: پوچ
2026-06-10 14:06:37 | INFO | user | Spin result: پوچ
2026-06-10 14:07:42 | INFO | admin | Item updated: 3
2026-06-10 14:07:42 | INFO | admin | Items fetched: 6 items
2026-06-10 14:07:48 | INFO | user | Spin result: 1000 لایو کوین
2026-06-10 14:08:03 | INFO | user | Spin result: پوچ
2026-06-10 14:08:15 | INFO | admin | Item deleted: 6
2026-06-10 14:08:15 | INFO | admin | Items fetched: 5 items
2026-06-10 14:08:25 | INFO | admin | Item updated: 2
2026-06-10 14:08:25 | INFO | admin | Items fetched: 5 items
2026-06-10 14:08:30 | INFO | user | Spin result: 1000 لایو کوین
2026-06-10 14:08:38 | INFO | user | Spin result: 1000 لایو کوین
2026-06-10 14:08:47 | INFO | user | User fetched items: 5 items
2026-06-10 14:08:50 | INFO | user | Spin result: 1000 لایو کوین
2026-06-10 14:09:40 | INFO | user | Spin result: 1000 لایو کوین
2026-06-10 14:11:30 | INFO | user | User fetched items: 5 items
2026-06-10 14:12:04 | INFO | admin | Item created: تخفيف 20
2026-06-10 14:12:04 | INFO | admin | Items fetched: 6 items
2026-06-10 14:12:09 | INFO | user | User fetched items: 6 items
2026-06-10 14:12:16 | INFO | user | User fetched items: 6 items
2026-06-10 14:13:32 | INFO | user | User fetched items: 6 items
2026-06-10 14:13:34 | INFO | user | Spin result: تخفيف 20
2026-06-10 14:13:54 | INFO | user | Spin result: 1000 لایو کوین
2026-06-10 14:14:26 | INFO | user | Spin result: 1000 لایو کوین
2026-06-10 14:15:30 | INFO | admin | Items fetched: 6 items
2026-06-10 14:15:39 | INFO | user | User fetched items: 6 items
2026-06-10 14:16:38 | INFO | user | User fetched items: 6 items
2026-06-10 14:16:44 | INFO | admin | Items fetched: 6 items
2026-06-10 14:17:30 | INFO | admin | Items fetched: 6 items
2026-06-10 14:21:12 | INFO | user | Spin result: تخفيف 20
2026-06-10 14:21:20 | INFO | user | Spin result: تخفيف 30
2026-06-10 14:21:30 | INFO | user | Spin result: تخفيف 20
2026-06-10 14:43:11 | INFO | admin | Items fetched: 6 items
2026-06-10 14:45:05 | INFO | user | User fetched items: 6 items
2026-06-10 14:45:40 | INFO | user | User fetched items: 6 items
2026-06-10 14:46:11 | INFO | user | Spin result: تخفيف 30
2026-06-10 14:46:52 | INFO | user | User fetched items: 6 items

View File

@@ -38,3 +38,35 @@ FROM items]
[SQL: INSERT INTO items (title) VALUES (?)]
[parameters: ('جایزه 1',)]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-03 02:01:07 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.type
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.type AS items_type, items.description AS items_description, items.probability AS items_probability, items.value AS items_value
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-03 02:01:11 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.type
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.type AS items_type, items.description AS items_description, items.probability AS items_probability, items.value AS items_value
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-03 02:08:13 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.type
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.type AS items_type, items.description AS items_description, items.probability AS items_probability, items.value AS items_value
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-03 02:30:22 | CRITICAL | admin | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.prize
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.prize AS items_prize, items.description AS items_description, items.prize_type AS items_prize_type, items.probability AS items_probability
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-07 00:22:06 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.prize
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.prize AS items_prize, items.description AS items_description, items.prize_type AS items_prize_type, items.probability AS items_probability
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-07 00:22:08 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.prize
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.prize AS items_prize, items.description AS items_description, items.prize_type AS items_prize_type, items.probability AS items_probability
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-07 00:23:31 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.prize
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.prize AS items_prize, items.description AS items_description, items.prize_type AS items_prize_type, items.probability AS items_probability
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-06-07 00:24:02 | CRITICAL | user | Unexpected error fetching items: (sqlite3.OperationalError) no such column: items.prize
[SQL: SELECT items.id AS items_id, items.title AS items_title, items.prize AS items_prize, items.description AS items_description, items.prize_type AS items_prize_type, items.probability AS items_probability
FROM items]
(Background on this error at: https://sqlalche.me/e/20/e3q8)

View File

@@ -1,19 +1,14 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.db.session import engine
from app.db.session import Base
from app.db.session import engine, Base
from app.db.models import Item
app = FastAPI()
from api.admin import router as admin_router
from api.user import router as user_router
app.include_router(user_router, prefix="/user")
app = FastAPI()
Base.metadata.create_all(bind=engine)
app.add_middleware(
CORSMiddleware,
@@ -26,4 +21,8 @@ app.add_middleware(
allow_headers=["*"],
)
# ✅ بعد router ها
app.include_router(user_router, prefix="/user")
app.include_router(admin_router, prefix="/admin")
Base.metadata.create_all(bind=engine)

View File

@@ -1,20 +1,27 @@
from pydantic import BaseModel
from typing import Optional
class ItemCreate(BaseModel):
title: str
value: int
prize: str
description: Optional[str] = ""
prize_type: str = "پوچ"
probability: float = 1.0
class ItemUpdate(BaseModel):
title: str
value: int
prize: str
description: Optional[str] = ""
prize_type: str = "پوچ"
probability: float = 1.0
class ItemResponse(BaseModel):
id: int
title: str
value: int
prize: str
description: Optional[str] = ""
prize_type: str
probability: float
class Config:
from_attributes = True

View File

@@ -5,18 +5,27 @@ from app.db.models import Item
def get_items(db: Session):
return db.query(Item).all()
def create_item(db: Session, title: str, value: int): # ✅ prize → value
item = Item(title=title, value=value) # ✅ prize → value
def create_item(db: Session, title: str, prize: str, description: str = "", prize_type: str = "پوچ", probability: float = 1.0):
item = Item(
title=title,
prize=prize,
description=description,
prize_type=prize_type,
probability=probability
)
db.add(item)
db.commit()
db.refresh(item)
return item
def update_item(db: Session, item_id: int, title: str, value: int): # ✅ prize → value
def update_item(db: Session, item_id: int, title: str, prize: str, description: str = "", prize_type: str = "پوچ", probability: float = 1.0):
item = db.query(Item).filter(Item.id == item_id).first()
if item:
item.title = title
item.value = value # ✅ prize → value
item.prize = prize
item.description = description
item.prize_type = prize_type
item.probability = probability
db.commit()
db.refresh(item)
return item
@@ -32,4 +41,5 @@ def spin_wheel(db: Session):
items = db.query(Item).all()
if not items:
return None
return random.choice(items)
weights = [item.probability for item in items]
return random.choices(items, weights=weights, k=1)[0]

Binary file not shown.

View File

@@ -1,92 +1,161 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>پنل مدیریت</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<body id="main-body">
<div class="admin-container">
<div class="admin-header">
<h1>مدیریت گردونه شانس</h1>
<p>مدیریت آیتم‌ها و جوایز گردونه</p>
</div>
<!-- تنظیمات رنگ پس‌زمینه -->
<div class="card">
<h2>تنظیمات ظاهری</h2>
<div class="color-section">
<label>رنگ پس‌زمینه:</label>
<div class="color-presets">
<div class="preset" style="background:#7B241C" data-color="#7B241C"></div>
<div class="preset" style="background:#1a237e" data-color="#1a237e"></div>
<div class="preset" style="background:#1b5e20" data-color="#1b5e20"></div>
<div class="preset" style="background:#4a148c" data-color="#4a148c"></div>
<div class="preset" style="background:#e65100" data-color="#e65100"></div>
<div class="preset" style="background:#006064" data-color="#006064"></div>
<div class="preset" style="background:#212121" data-color="#212121"></div>
<div class="preset" style="background:#880e4f" data-color="#880e4f"></div>
</div>
<div class="color-custom">
<label>رنگ دلخواه:</label>
<input type="color" id="bg-color-picker" value="#7B241C">
<button id="apply-color">اعمال</button>
</div>
<div class="color-preview">
<span>پیش‌نمایش:</span>
<div id="color-preview-box"></div>
</div>
</div>
</div>
<!-- عنوان صفحه گردونه -->
<div class="title-section">
<h3>عنوان صفحه گردونه</h3>
<div class="form-grid">
<div class="form-group">
<label>متن عنوان:</label>
<input type="text" id="title-text-input" placeholder="گردونه شانس">
</div>
<div class="form-group">
<label>رنگ عنوان:</label>
<div style="display:flex; align-items:center; gap:10px;">
<input type="color" id="title-color-picker" value="#FFD700">
<span id="title-preview" style="font-size:20px; font-weight:bold; color:#FFD700;">گردونه شانس</span>
</div>
</div>
<div class="form-group full-width">
<button id="apply-title" class="btn-primary">اعمال</button>
</div>
</div>
</div>
<!-- افزودن آیتم -->
<div class="add-section">
<input
type="text"
id="title-input"
placeholder="عنوان جایزه"
>
<input
type="number"
id="value-input"
placeholder="مقدار جایزه"
>
<button id="add-btn">
افزودن
</button>
<div class="card">
<h2>افزودن آیتم جدید</h2>
<div class="form-grid">
<div class="form-group">
<label>نوع جایزه:</label>
<select id="new-prize-type">
<option value="پوچ">پوچ</option>
<option value="کد تخفیف">کد تخفیف</option>
<option value="لایوکوین">لایوکوین (LC)</option>
</select>
</div>
<div class="form-group">
<label>عنوان:</label>
<input type="text" id="new-title" placeholder="مثلاً: تخفیف ۲۰٪">
</div>
<div class="form-group">
<label>مقدار جایزه:</label>
<input type="text" id="new-prize" placeholder="مثلاً: DISC20">
</div>
<div class="form-group">
<label>احتمال (۱ = عادی):</label>
<input type="number" id="new-probability" value="1" min="0.1" step="0.1">
</div>
<div class="form-group full-width">
<label>توضیحات:</label>
<textarea id="new-description" placeholder="توضیحاتی که بعد از برنده شدن نمایش داده میشه..."></textarea>
</div>
<div class="form-group full-width">
<button id="add-btn" class="btn-primary">افزودن آیتم</button>
</div>
</div>
</div>
<!-- جدول -->
<div class="card">
<h2>لیست آیتم‌ها</h2>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>عنوان جایزه</th>
<th>نوع جایزه</th>
<th>عنوان</th>
<th>مقدار</th>
<th>احتمال</th>
<th>توضیحات</th>
<th>عملیات</th>
</tr>
</thead>
<tbody id="items-table-body">
<!-- ردیف نمونه (دینامیک توسط JS ساخته می‌شود) -->
<!--
<tr>
<td>1</td>
<td>جایزه تست</td>
<td>100</td>
<td class="actions">
<button class="edit-btn" data-id="1">
✏️ ویرایش
</button>
<button class="delete-btn" data-id="1">
🗑 حذف
</button>
</td>
</tr>
-->
</tbody>
<tbody id="items-table-body"></tbody>
</table>
</div>
</div>
</div>
<!-- مودال ویرایش -->
<div id="edit-modal" class="modal hidden">
<div class="modal-content admin-modal">
<h2>ویرایش آیتم</h2>
<input type="hidden" id="edit-id">
<div class="form-grid">
<div class="form-group">
<label>نوع جایزه:</label>
<select id="edit-prize-type">
<option value="پوچ">پوچ</option>
<option value="کد تخفیف">کد تخفیف</option>
<option value="لایوکوین">لایوکوین (LC)</option>
</select>
</div>
<div class="form-group">
<label>عنوان:</label>
<input type="text" id="edit-title">
</div>
<div class="form-group">
<label>مقدار جایزه:</label>
<input type="text" id="edit-prize">
</div>
<div class="form-group">
<label>احتمال:</label>
<input type="number" id="edit-probability" min="0.1" step="0.1">
</div>
<div class="form-group full-width">
<label>توضیحات:</label>
<textarea id="edit-description"></textarea>
</div>
</div>
<div class="modal-actions">
<button id="save-edit" class="btn-primary">ذخیره</button>
<button id="cancel-edit" class="btn-secondary">انصراف</button>
</div>
</div>
</div>
<script type="module" src="assets/js/admin.js"></script>
</body>
</html>

View File

@@ -1,261 +1,68 @@
/* =========================
ADMIN PANEL
========================= */
.admin-container {
width: 100%;
max-width: 1200px;
padding: 30px;
@font-face {
font-family: 'Dana';
src: url('../fonts/DanaFaNum-Regular.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
}
.admin-header {
text-align: center;
margin-bottom: 30px;
}
.admin-header h1 {
color: #FFD700;
font-size: 38px;
text-shadow: 0 0 15px rgba(255,215,0,0.5);
}
.admin-header p {
color: #f5d6a0;
margin-top: 10px;
font-size: 18px;
}
.add-section {
background: rgba(0,0,0,0.25);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
padding: 20px;
display: flex;
gap: 15px;
margin-bottom: 30px;
box-shadow:
0 10px 30px rgba(0,0,0,0.4),
inset 0 0 20px rgba(255,255,255,0.03);
}
.add-section input {
flex: 1;
padding: 14px;
border: none;
border-radius: 12px;
background: rgba(255,255,255,0.08);
color: white;
font-size: 16px;
outline: none;
}
.add-section input::placeholder {
color: rgba(255,255,255,0.5);
}
#add-btn {
padding: 14px 28px;
background: linear-gradient(
135deg,
#FFD700,
#D4AC0D
);
color: #4a0f0f;
border: none;
border-radius: 14px;
font-weight: bold;
cursor: pointer;
transition: 0.2s;
box-shadow:
0 4px 20px rgba(255,215,0,0.3);
}
#add-btn:hover {
transform: translateY(-2px);
box-shadow:
0 8px 25px rgba(255,215,0,0.45);
}
.table-wrapper {
background: rgba(0,0,0,0.25);
backdrop-filter: blur(10px);
border-radius: 22px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
box-shadow:
0 10px 40px rgba(0,0,0,0.45);
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background:
linear-gradient(
135deg,
#FFD700,
#D4AC0D
);
color: #4a0f0f;
}
th {
padding: 18px;
font-size: 17px;
}
td {
padding: 18px;
text-align: center;
color: white;
}
tbody tr {
border-bottom:
1px solid rgba(255,255,255,0.06);
transition: 0.2s;
}
tbody tr:hover {
background:
rgba(255,255,255,0.05);
}
.action-buttons {
display: flex;
justify-content: center;
gap: 10px;
}
.edit-btn,
.delete-btn {
border: none;
padding: 10px 16px;
border-radius: 10px;
cursor: pointer;
font-weight: bold;
transition: 0.2s;
}
.edit-btn {
background:
linear-gradient(
135deg,
#9B59B6,
#7D3C98
);
color: white;
}
.delete-btn {
background:
linear-gradient(
135deg,
#E74C3C,
#922B21
);
color: white;
}
.edit-btn:hover,
.delete-btn:hover {
transform: scale(1.05);
}
@media (max-width: 768px) {
.add-section {
flex-direction: column;
}
table {
font-size: 14px;
}
th,
td {
padding: 12px;
}
}
/* =========================
WHEEL PAGE
========================= */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Dana' !important;
}
/* =========================
WHEEL PAGE
========================= */
body {
min-height: 100vh;
background: linear-gradient(135deg, #8B0000 0%, #4a0f0f 50%, #2d0000 100%);
background: radial-gradient(ellipse at center, #9B241CDD 0%, #7B241C 55%, #7B241CBB 100%);
display: flex;
justify-content: center;
align-items: center;
font-family: Tahoma, sans-serif;
direction: rtl;
}
.bg-rays {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 200vw;
height: 200vh;
background: repeating-conic-gradient(
rgba(255,255,255,0.03) 0deg,
rgba(255,255,255,0.03) 10deg,
transparent 10deg,
transparent 20deg
);
pointer-events: none;
z-index: 0;
}
.page {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
gap: 28px;
padding: 30px;
}
.page h1 {
color: #FFD700;
font-size: 36px;
color: #e9e3c0;
font-size: 42px;
font-weight: bold;
text-shadow: 0 0 20px rgba(255,215,0,0.5);
letter-spacing: 2px;
}
.wheel-wrapper {
position: relative;
width: 500px;
height: 500px;
width: 520px;
height: 520px;
}
.wheel-wrapper canvas {
@@ -265,11 +72,10 @@ body {
}
#spin-btn {
padding: 16px 48px;
font-size: 20px;
padding: 18px 56px;
font-size: 24px;
font-weight: bold;
font-family: Tahoma, sans-serif;
background: linear-gradient(135deg, #FFD700, #D4AC0D);
background: linear-gradient(135deg, #e7d78a, #f2dd9d);
color: #4a0f0f;
border: none;
border-radius: 50px;
@@ -295,7 +101,7 @@ body {
}
.winner-text {
font-size: 28px;
font-size: 32px;
font-weight: bold;
color: #FFD700;
text-shadow: 0 0 20px rgba(255,215,0,0.7);
@@ -308,18 +114,380 @@ body {
100% { transform: scale(1); opacity: 1; }
}
@media (max-width: 550px) {
.wheel-wrapper {
width: 340px;
height: 340px;
/* =========================
مودال برنده
========================= */
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.page h1 {
.modal.hidden { display: none; }
.modal-content {
background: white;
border-radius: 20px;
padding: 44px 36px;
text-align: center;
max-width: 420px;
width: 90%;
animation: popIn 0.4s ease;
direction: rtl;
}
@keyframes popIn {
0% { transform: scale(0.5); opacity: 0; }
70% { transform: scale(1.05); }
100% { transform: scale(1); opacity: 1; }
}
.modal-icon { font-size: 70px; margin-bottom: 12px; }
.modal-content h2 {
color: #7B241C;
font-size: 30px;
font-weight: bold;
margin-bottom: 10px;
}
#modal-prize-type {
color: #888;
font-size: 17px;
margin-bottom: 8px;
}
#modal-prize {
color: #C0392B;
font-size: 26px;
font-weight: bold;
margin-bottom: 14px;
}
#spin-btn {
#modal-description {
color: #555;
font-size: 17px;
margin-bottom: 28px;
line-height: 2;
}
#modal-close {
padding: 14px 48px;
background: linear-gradient(135deg, #FFD700, #D4AC0D);
color: #4a0f0f;
border: none;
border-radius: 50px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
}
/* =========================
ADMIN PANEL
========================= */
.admin-container {
width: 150%;
max-width: 1100px;
margin: 0 auto;
padding: 30px 20px;
display: flex;
flex-direction: column;
gap: 24px;
}
.admin-header {
text-align: center;
margin-bottom: 20px;
}
.admin-header h1 {
color: #e9e8df;
font-size: 40px;
font-weight: bold;
text-shadow: 0 0 15px rgba(255,215,0,0.5);
}
.admin-header p {
color: #f5d6a0;
margin-top: 8px;
font-size: 18px;
}
.card {
background: rgba(0,0,0,0.25);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
padding: 24px;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
}
.card h2 {
color: #efede1;
font-size: 20px;
font-weight: bold;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.color-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.color-section label {
color: #f5d6a0;
font-size: 16px;
padding: 12px 36px;
}
.color-presets {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.preset {
width: 44px;
height: 44px;
border-radius: 8px;
cursor: pointer;
border: 3px solid transparent;
transition: transform 0.2s, border-color 0.2s;
}
.preset:hover { transform: scale(1.15); }
.preset.active { border-color: #e5e3d5; }
.color-custom {
display: flex;
align-items: center;
gap: 12px;
}
.color-preview {
display: flex;
align-items: center;
gap: 12px;
color: #f5d6a0;
font-size: 16px;
}
#color-preview-box {
width: 120px;
height: 36px;
border-radius: 8px;
border: 2px solid rgba(255,255,255,0.2);
transition: background 0.3s;
}
input[type="color"] {
width: 50px;
height: 38px;
border: none;
border-radius: 6px;
cursor: pointer;
padding: 2px;
background: transparent;
}
#apply-color {
padding: 10px 20px;
background: rgba(255,255,255,0.15);
color: white;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
cursor: pointer;
font-size: 15px;
transition: 0.2s;
}
#apply-color:hover { background: rgba(255,255,255,0.25); }
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group.full-width { grid-column: 1 / -1; }
.form-group label {
font-size: 15px;
color: #f5d6a0;
font-weight: bold;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 12px 14px;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
font-size: 15px;
background: rgba(255,255,255,0.08);
color: white;
transition: border-color 0.2s;
}
.form-group input::placeholder,
.form-group textarea::placeholder {
color: rgba(255,255,255,0.4);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #e4e2d7;
}
.form-group select option {
background: #4a0f0f;
color: white;
}
.form-group textarea { height: 90px; resize: vertical; }
.btn-primary {
padding: 13px 32px;
background: linear-gradient(135deg, #e3dfb7, #ebe5be);
color: #4a0f0f;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: 0.2s;
}
.btn-primary:hover { transform: translateY(-2px); }
.btn-secondary {
padding: 13px 32px;
background: rgba(255,255,255,0.1);
color: white;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: 0.2s;
}
.btn-secondary:hover { background: rgba(255,255,255,0.2); }
.table-wrapper {
overflow-x: auto;
border-radius: 12px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 15px;
}
thead tr {
background: linear-gradient(135deg, #ebe3b9, #ebe1bc);
color: #0f374a;
}
th, td {
padding: 14px 16px;
text-align: right;
}
tbody tr {
border-bottom: 1px solid rgba(255,255,255,0.06);
color: white;
transition: 0.2s;
}
tbody tr:hover { background: rgba(255,255,255,0.05); }
.badge {
padding: 5px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: bold;
}
.badge-pooch { background: rgba(255,255,255,0.1); color: #ccc; }
.badge-discount { background: rgba(46,125,50,0.3); color: #81C784; }
.badge-coin { background: rgba(245,127,23,0.3); color: #FFD700; }
.edit-btn {
padding: 7px 16px;
background: linear-gradient(135deg, #9B59B6, #7D3C98);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
margin-left: 6px;
transition: 0.2s;
}
.delete-btn {
padding: 7px 16px;
background: linear-gradient(135deg, #E74C3C, #922B21);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: 0.2s;
}
.edit-btn:hover, .delete-btn:hover { transform: scale(1.05); }
.modal-content.admin-modal {
background: #2d0000;
color: white;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.modal-content.admin-modal h2 {
color: #FFD700;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 12px;
margin-top: 20px;
justify-content: flex-end;
}
@media (max-width: 550px) {
.wheel-wrapper { width: 340px; height: 340px; }
.page h1 { font-size: 28px; }
#spin-btn { font-size: 18px; padding: 14px 40px; }
.form-grid { grid-template-columns: 1fr; }
}
.title-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.title-section h3 {
color: #f5d6a0;
font-size: 15px;
margin-bottom: 14px;
font-family: 'Dana', Tahoma, sans-serif;
}

Binary file not shown.

View File

@@ -1,103 +1,158 @@
import {
adminGetItems,
createItem,
deleteItem,
updateItem // 👈 باید اینو هم به api.js اضافه کنی
} from './api.js';
import { adminGetItems, createItem, deleteItem, updateItem, saveBgColor, getBgColor, saveTitleSettings, getTitleSettings } from './api.js';
const tableBody = document.getElementById('items-table-body');
const titleInput = document.getElementById('title-input');
const valueInput = document.getElementById('value-input');
const addBtn = document.getElementById('add-btn');
const editModal = document.getElementById('edit-modal');
const saveEdit = document.getElementById('save-edit');
const cancelEdit = document.getElementById('cancel-edit');
const colorPicker = document.getElementById('bg-color-picker');
const applyColor = document.getElementById('apply-color');
const previewBox = document.getElementById('color-preview-box');
const presets = document.querySelectorAll('.preset');
let editId = null; // 👈 حالت ویرایش
// -------------------- Render --------------------
async function renderItems() {
const items = await adminGetItems();
tableBody.innerHTML = '';
items.forEach((item, index) => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${index + 1}</td>
<td>${item.title}</td>
<td>${item.value}</td>
<td class="actions">
<button
class="edit-btn"
onclick="editItem(${item.id}, '${item.title}', ${item.value})"
>
✏️ ویرایش
</button>
<button
class="delete-btn"
onclick="removeItem(${item.id})"
>
🗑 حذف
</button>
</td>
`;
tableBody.appendChild(row);
// ═══ رنگ پس‌زمینه ═══
function initColor() {
const saved = getBgColor();
colorPicker.value = saved;
previewBox.style.background = saved;
presets.forEach(p => {
if (p.dataset.color === saved) p.classList.add('active');
});
}
// -------------------- Delete --------------------
presets.forEach(p => {
p.addEventListener('click', () => {
presets.forEach(x => x.classList.remove('active'));
p.classList.add('active');
colorPicker.value = p.dataset.color;
previewBox.style.background = p.dataset.color;
});
});
colorPicker.addEventListener('input', () => {
previewBox.style.background = colorPicker.value;
presets.forEach(x => x.classList.remove('active'));
});
applyColor.addEventListener('click', () => {
saveBgColor(colorPicker.value);
applyColor.textContent = '✓ ذخیره شد';
setTimeout(() => applyColor.textContent = 'اعمال', 2000);
});
// ═══ عنوان صفحه گردونه ═══
function initTitle() {
const settings = getTitleSettings();
document.getElementById('title-text-input').value = settings.text;
document.getElementById('title-color-picker').value = settings.color;
document.getElementById('title-preview').textContent = settings.text;
document.getElementById('title-preview').style.color = settings.color;
}
document.getElementById('title-text-input').addEventListener('input', (e) => {
document.getElementById('title-preview').textContent = e.target.value || 'گردونه شانس';
});
document.getElementById('title-color-picker').addEventListener('input', (e) => {
document.getElementById('title-preview').style.color = e.target.value;
});
document.getElementById('apply-title').addEventListener('click', () => {
const text = document.getElementById('title-text-input').value.trim() || 'گردونه شانس';
const color = document.getElementById('title-color-picker').value;
saveTitleSettings(text, color);
document.getElementById('apply-title').textContent = '✓ ذخیره شد';
setTimeout(() => document.getElementById('apply-title').textContent = 'اعمال', 2000);
});
// ═══ badge نوع جایزه ═══
function prizeBadge(type) {
const map = {
'پوچ': ['badge-pooch', 'پوچ'],
'کد تخفیف': ['badge-discount', 'کد تخفیف'],
'لایوکوین': ['badge-coin', 'لایوکوین (LC)'],
};
const [cls, label] = map[type] || ['badge-pooch', type];
return `<span class="badge ${cls}">${label}</span>`;
}
// ═══ رندر جدول ═══
async function renderItems() {
const items = await adminGetItems();
tableBody.innerHTML = '';
items.forEach((item, idx) => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${idx + 1}</td>
<td>${prizeBadge(item.prize_type)}</td>
<td>${item.title}</td>
<td>${item.prize}</td>
<td>${item.probability}</td>
<td>${item.description || '—'}</td>
<td>
<button class="edit-btn" onclick="openEdit(${item.id})">✏️ ویرایش</button>
<button class="delete-btn" onclick="removeItem(${item.id})">🗑 حذف</button>
</td>
`;
tableBody.appendChild(row);
});
window._items = items;
}
// ═══ افزودن آیتم ═══
addBtn.onclick = async () => {
const data = {
prize_type: document.getElementById('new-prize-type').value,
title: document.getElementById('new-title').value.trim(),
prize: document.getElementById('new-prize').value.trim(),
probability: parseFloat(document.getElementById('new-probability').value),
description: document.getElementById('new-description').value.trim(),
};
if (!data.title || !data.prize) return alert('عنوان و مقدار جایزه رو پر کن');
await createItem(data);
document.getElementById('new-title').value = '';
document.getElementById('new-prize').value = '';
document.getElementById('new-description').value = '';
document.getElementById('new-probability').value = '1';
renderItems();
};
// ═══ حذف ═══
window.removeItem = async (id) => {
if (!confirm('آیا مطمئنی؟')) return;
await deleteItem(id);
renderItems();
};
// -------------------- Edit --------------------
window.editItem = (id, title, value) => {
titleInput.value = title;
valueInput.value = value;
editId = id;
addBtn.textContent = 'آپدیت ✏️';
// ═══ ویرایش ═══
window.openEdit = (id) => {
const item = window._items.find(i => i.id === id);
if (!item) return;
document.getElementById('edit-id').value = item.id;
document.getElementById('edit-prize-type').value = item.prize_type;
document.getElementById('edit-title').value = item.title;
document.getElementById('edit-prize').value = item.prize;
document.getElementById('edit-probability').value = item.probability;
document.getElementById('edit-description').value = item.description || '';
editModal.classList.remove('hidden');
};
// -------------------- Add / Update --------------------
addBtn.onclick = async () => {
const title = titleInput.value.trim();
const value = valueInput.value.trim();
if (!title || !value) {
alert('همه فیلدها را پر کن');
return;
}
// حالت ویرایش
if (editId) {
await updateItem(editId, {
title,
value: parseInt(value)
});
editId = null;
addBtn.textContent = 'افزودن';
}
// حالت افزودن
else {
await createItem({
title,
value: parseInt(value)
});
}
titleInput.value = '';
valueInput.value = '';
cancelEdit.onclick = () => editModal.classList.add('hidden');
saveEdit.onclick = async () => {
const id = document.getElementById('edit-id').value;
const data = {
prize_type: document.getElementById('edit-prize-type').value,
title: document.getElementById('edit-title').value.trim(),
prize: document.getElementById('edit-prize').value.trim(),
probability: parseFloat(document.getElementById('edit-probability').value),
description: document.getElementById('edit-description').value.trim(),
};
await updateItem(id, data);
editModal.classList.add('hidden');
renderItems();
};
// -------------------- Init --------------------
initColor();
initTitle();
renderItems();

View File

@@ -1,40 +1,74 @@
const BASE = 'http://127.0.0.1:8000';
// ---------------- USER ----------------
export const getItems = () =>
fetch(`${BASE}/user/items`)
.then(r => r.json());
fetch(`${BASE}/user/items`).then(r => r.json());
export const spinWheel = () =>
fetch(`${BASE}/user/spin`, {
method: 'POST'
}).then(r => r.json());
fetch(`${BASE}/user/spin`, { method: 'POST' }).then(async (r) => {
if (!r.ok) throw new Error('Spin failed');
return r.json();
});
// ---------------- ADMIN ----------------
export const adminGetItems = () =>
fetch(`${BASE}/admin/items`)
.then(r => r.json());
fetch(`${BASE}/admin/items`).then(r => r.json());
export const createItem = (data) =>
fetch(`${BASE}/admin/items`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: data.title,
prize: data.prize,
prize_type: data.prize_type,
description: data.description,
probability: data.probability
})
}).then(r => r.json());
export const updateItem = (id, data) =>
fetch(`${BASE}/admin/items/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: data.title,
prize: data.prize,
prize_type: data.prize_type,
description: data.description,
probability: data.probability
})
}).then(r => r.json());
export const deleteItem = (id) =>
fetch(`${BASE}/admin/items/${id}`, {
method: 'DELETE'
}).then(r => {
if (r.status === 204) return {};
return r.json();
fetch(`${BASE}/admin/items/${id}`, { method: 'DELETE' }).then(async (r) => {
if (!r.ok && r.status !== 204) throw new Error('Delete failed');
return {};
});
// ---------------- رنگ پس‌زمینه ----------------
export const getBgColor = () =>
localStorage.getItem('wheelBgColor') || '#7B241C';
export const saveBgColor = (color) => {
localStorage.setItem('wheelBgColor', color);
window.dispatchEvent(new StorageEvent('storage', {
key: 'wheelBgColor',
newValue: color
}));
};
// ---------------- عنوان صفحه گردونه ----------------
export const getTitleSettings = () => {
const saved = localStorage.getItem('wheelTitleSettings');
return saved ? JSON.parse(saved) : { text: 'گردونه شانس', color: '#FFD700' };
};
export const saveTitleSettings = (text, color) => {
const settings = { text, color };
localStorage.setItem('wheelTitleSettings', JSON.stringify(settings));
window.dispatchEvent(new StorageEvent('storage', {
key: 'wheelTitleSettings',
newValue: JSON.stringify(settings)
}));
};

View File

@@ -1,4 +1,4 @@
import { getItems, spinWheel } from './api.js';
import { getItems, spinWheel, getBgColor, getTitleSettings } from './api.js';
const canvas = document.getElementById('wheel');
const ctx = canvas.getContext('2d');
@@ -8,12 +8,43 @@ const result = document.getElementById('result');
const DARK = { edge: '#6D0025', main: '#cb183d', highlight: '#E8005E', text: '#fff' };
const LIGHT = { edge: '#D4889A', main: '#FAD4DC', highlight: '#FFF0F3', text: '#7B0030' };
let audioCtx = null;
function getAudioCtx() {
if (!audioCtx || audioCtx.state === 'closed') {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
let items = [];
let angle = 0;
let spinning = false;
let lampOn = true;
function applyBg() {
const color = getBgColor();
document.getElementById('main-body').style.background =
`radial-gradient(ellipse at center, ${color}DD 0%, ${color} 55%, ${color}BB 100%)`;
}
function applyTitle() {
const settings = getTitleSettings();
const h1 = document.querySelector('.page h1');
if (h1) {
h1.textContent = settings.text;
h1.style.color = settings.color;
h1.style.textShadow = `0 0 20px ${settings.color}80`;
}
}
async function init() {
applyBg();
applyTitle();
window.addEventListener('storage', (e) => {
if (e.key === 'wheelBgColor') applyBg();
if (e.key === 'wheelTitleSettings') applyTitle();
});
items = await getItems();
draw(angle);
setInterval(() => {
@@ -75,7 +106,6 @@ function draw(rotation) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ═══ سایه کلی ═══
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.6)';
ctx.shadowBlur = 30;
@@ -87,7 +117,6 @@ function draw(rotation) {
ctx.fill();
ctx.restore();
// ═══ حلقه بیرونی تیره ═══
const rimGrad = ctx.createRadialGradient(cx - 28, cy - 28, r, cx, cy, r + 40);
rimGrad.addColorStop(0, '#A0003A');
rimGrad.addColorStop(0.3, '#6D0025');
@@ -98,14 +127,12 @@ function draw(rotation) {
ctx.fillStyle = rimGrad;
ctx.fill();
// ═══ بخش‌های گردونه ═══
items.forEach((item, i) => {
const start = rotation + i * slice;
const end = start + slice;
drawSegment(cx, cy, r, start, end, getTheme(i, total));
});
// ═══ خطوط طلایی ═══
items.forEach((_, i) => {
const lineAngle = rotation + i * slice;
const lGrad = ctx.createLinearGradient(
@@ -113,7 +140,7 @@ function draw(rotation) {
cx + r * Math.cos(lineAngle),
cy + r * Math.sin(lineAngle)
);
lGrad.addColorStop(0, 'rgba(700,550,0,5)');
lGrad.addColorStop(0, 'rgba(255,215,0,0.3)');
lGrad.addColorStop(0.4, '#a2922e');
lGrad.addColorStop(1, '#c8b43b');
ctx.beginPath();
@@ -124,31 +151,29 @@ function draw(rotation) {
ctx.stroke();
});
// ═══ متن آیتم‌ها ═══
items.forEach((item, i) => {
const start = rotation + i * slice;
const theme = getTheme(i, total);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(start + slice / 2);
ctx.textAlign = 'right';
ctx.textAlign = 'center';
ctx.fillStyle = theme.text;
ctx.font = 'bold 12px Tahoma';
ctx.font = 'bold 18px Dana';
ctx.shadowColor = 'rgba(0,0,0,0.6)';
ctx.shadowBlur = 4;
const text = item.title;
const maxWidth = r - 52;
if (ctx.measureText(text).width > maxWidth) {
const mid = Math.ceil(text.length / 2);
ctx.fillText(text.slice(0, mid), r - 20, -7);
ctx.fillText(text.slice(mid), r - 20, 9);
ctx.fillText(text.slice(0, mid), (r - 20) / 1.4, -9);
ctx.fillText(text.slice(mid), (r - 20) / 1.4, 9);
} else {
ctx.fillText(text, r - 20, 5);
ctx.fillText(text, (r - 20) / 1.4, 5);
}
ctx.restore();
});
// ═══ حلقه طلایی داخلی ═══
ctx.beginPath();
ctx.arc(cx, cy, r + 2, 0, Math.PI * 2);
ctx.strokeStyle = '#dfd498';
@@ -158,7 +183,6 @@ function draw(rotation) {
ctx.stroke();
ctx.shadowBlur = 0;
// ═══ لامپ‌ها ═══
const lampCount = 24;
const lampR = r + 23;
for (let i = 0; i < lampCount; i++) {
@@ -208,7 +232,6 @@ function draw(rotation) {
ctx.fill();
}
// ═══ حلقه طلایی بیرونی ═══
ctx.beginPath();
ctx.arc(cx, cy, r + 35, 0, Math.PI * 2);
ctx.strokeStyle = '#C8960C';
@@ -218,7 +241,6 @@ function draw(rotation) {
ctx.stroke();
ctx.shadowBlur = 0;
// ═══ دایره مرکزی — هماهنگ با نشانگر ═══
const cGrad = ctx.createRadialGradient(cx - 8, cy - 8, 1, cx, cy, 22);
cGrad.addColorStop(0, '#FFFFF0');
cGrad.addColorStop(0.15, '#FFE566');
@@ -240,18 +262,15 @@ function draw(rotation) {
ctx.lineWidth = 2;
ctx.stroke();
// بازتاب روی دایره مرکزی
ctx.beginPath();
ctx.arc(cx - 6, cy - 6, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fill();
// ═══ نشانگر ═══
drawPointer(cx);
}
function drawPointer(cx) {
// سایه
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowBlur = 8;
@@ -263,7 +282,6 @@ function drawPointer(cx) {
ctx.fill();
ctx.restore();
// دایره طلایی — همرنگ مرکز
const pGrad = ctx.createRadialGradient(cx - 6, 14, 1, cx, 20, 18);
pGrad.addColorStop(0, '#FFFFF0');
pGrad.addColorStop(0.15, '#FFE566');
@@ -282,13 +300,11 @@ function drawPointer(cx) {
ctx.lineWidth = 2;
ctx.stroke();
// بازتاب
ctx.beginPath();
ctx.arc(cx - 5, 13, 6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fill();
// مثلث — همرنگ
const triGrad = ctx.createLinearGradient(cx, 34, cx, 62);
triGrad.addColorStop(0, '#FFE566');
triGrad.addColorStop(0.35, '#FFD700');
@@ -309,6 +325,52 @@ function drawPointer(cx) {
ctx.stroke();
}
function playTickSound() {
try {
const actx = getAudioCtx();
const osc = actx.createOscillator();
const gain = actx.createGain();
osc.connect(gain);
gain.connect(actx.destination);
osc.frequency.value = 600;
osc.type = 'sine';
gain.gain.setValueAtTime(0.15, actx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, actx.currentTime + 0.08);
osc.start(actx.currentTime);
osc.stop(actx.currentTime + 0.08);
} catch(e) {}
}
function playWinSound() {
try {
const actx = getAudioCtx();
const notes = [523, 659, 784, 1047];
notes.forEach((freq, i) => {
const osc = actx.createOscillator();
const gain = actx.createGain();
osc.connect(gain);
gain.connect(actx.destination);
osc.frequency.value = freq;
osc.type = 'sine';
gain.gain.setValueAtTime(0.2, actx.currentTime + i * 0.15);
gain.gain.exponentialRampToValueAtTime(0.001, actx.currentTime + i * 0.15 + 0.3);
osc.start(actx.currentTime + i * 0.15);
osc.stop(actx.currentTime + i * 0.15 + 0.3);
});
} catch(e) {}
}
function showWinnerModal(winner) {
const modal = document.getElementById('winner-modal');
const iconMap = { 'پوچ': '', 'کد تخفیف': '', 'لایوکوین': '' };
document.getElementById('modal-icon').textContent = iconMap[winner.prize_type] || '🎉';
document.getElementById('modal-prize-type').textContent = winner.prize_type;
document.getElementById('modal-prize').textContent = winner.prize;
document.getElementById('modal-description').textContent = winner.description || '';
modal.classList.remove('hidden');
document.getElementById('modal-close').onclick = () => modal.classList.add('hidden');
}
btn.onclick = async () => {
if (spinning || items.length === 0) return;
spinning = true;
@@ -328,12 +390,20 @@ btn.onclick = async () => {
const duration = 5500;
const startTime = performance.now();
const from = angle;
let lastTickIdx = 0;
function animate(now) {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - t, 4);
angle = from + (targetAngle - from) * ease;
const currentIdx = Math.floor(Math.abs(angle) / slice);
if (currentIdx !== lastTickIdx) {
lastTickIdx = currentIdx;
playTickSound();
}
draw(angle);
if (t < 1) {
@@ -342,7 +412,8 @@ btn.onclick = async () => {
spinning = false;
btn.disabled = false;
draw(angle);
result.innerHTML = `<span class="winner-text">🎉 ${winner.title} 🎉</span>`;
playWinSound();
showWinnerModal(winner);
}
}

View File

@@ -2,18 +2,34 @@
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>گردونه شانس</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<body id="main-body">
<div class="bg-rays"></div>
<div class="page">
<h1> گردونه شانس</h1>
<div class="wheel-wrapper">
<canvas id="wheel" width="500" height="500"></canvas>
</div>
<button id="spin-btn">بچرخون!</button>
<button id="spin-btn">چرخوندن!</button>
<div id="result"></div>
</div>
<!-- مودال برنده -->
<div id="winner-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-icon" id="modal-icon">🎉</div>
<div id="modal-prize-type"></div>
<div id="modal-prize"></div>
<div id="modal-description"></div>
<button id="modal-close">باشه!</button>
</div>
</div>
<script type="module" src="assets/js/wheel.js"></script>
</body>
</html>