Added quiz new features

This commit is contained in:
m.razaghi
2026-08-19 12:18:35 +03:30
parent f84ef8c067
commit a6b2a1ba30
11 changed files with 549 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\DailyQuest;
use Illuminate\Http\Request;
class DailyQuestController extends Controller
{
public function index()
{
$quests = \App\Models\DailyQuest::latest()->paginate(15);
// Upgraded Bank of Smart Suggestions (Hardcore Focused)
$suggestions = [
// --- EXTREME CHALLENGES ---
['title' => 'اسطوره لیگ: پیروزی در ۵۰ دوئل', 'quest_type' => 'duel_win', 'required_amount' => 50, 'reward_xp' => 4000],
['title' => 'ماراتن تست‌زنی: پاسخ به ۵۰۰ تست', 'quest_type' => 'answer_tests', 'required_amount' => 500, 'reward_xp' => 3500],
['title' => 'کمال‌گرا: کسب نمره کامل در ۵ آزمون', 'quest_type' => 'perfect_score', 'required_amount' => 5, 'reward_xp' => 2500],
// --- HARD CHALLENGES ---
['title' => 'مبارز خستگی‌ناپذیر: پیروزی در ۲۰ دوئل', 'quest_type' => 'duel_win', 'required_amount' => 20, 'reward_xp' => 1500],
['title' => 'چالش حقوق‌دان: پاسخ به ۲۵۰ تست', 'quest_type' => 'answer_tests', 'required_amount' => 250, 'reward_xp' => 1200],
['title' => 'دقت بالا: کسب نمره کامل در ۳ آزمون', 'quest_type' => 'perfect_score', 'required_amount' => 3, 'reward_xp' => 1200],
['title' => 'پیروزی در ۱۰ دوئل متوالی (کاپ نقره)', 'quest_type' => 'duel_win', 'required_amount' => 10, 'reward_xp' => 700],
// --- MEDIUM CHALLENGES (For balance) ---
['title' => 'گرم‌کردن: پیروزی در ۵ دوئل', 'quest_type' => 'duel_win', 'required_amount' => 5, 'reward_xp' => 300],
['title' => 'مرور روزانه: پاسخ به ۵۰ تست', 'quest_type' => 'answer_tests', 'required_amount' => 50, 'reward_xp' => 250],
['title' => 'کسب نمره کامل در ۱ آزمون', 'quest_type' => 'perfect_score', 'required_amount' => 1, 'reward_xp' => 350],
];
return view('admin.daily-quests.index', compact('quests', 'suggestions'));
}
public function create()
{
return view('admin.daily-quests.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'quest_type' => 'required|string|in:duel_win,answer_tests,perfect_score',
'required_amount' => 'required|integer|min:1',
'reward_xp' => 'required|integer|min:10',
]);
$validated['is_active'] = $request->has('is_active'); // <-- Catch the switch
DailyQuest::create($validated);
return redirect()->route('daily-quests.index')->with('success', 'چالش روزانه با موفقیت ایجاد شد.');
}
public function update(Request $request, DailyQuest $daily_quest)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'quest_type' => 'required|string|in:duel_win,answer_tests,perfect_score',
'required_amount' => 'required|integer|min:1',
'reward_xp' => 'required|integer|min:10',
]);
$validated['is_active'] = $request->has('is_active'); // <-- Catch the switch
$daily_quest->update($validated);
return redirect()->route('daily-quests.index')->with('success', 'چالش روزانه با موفقیت ویرایش شد.');
}
public function edit(DailyQuest $daily_quest)
{
return view('admin.daily-quests.update', compact('daily_quest'));
}
public function destroy(DailyQuest $daily_quest)
{
$daily_quest->delete();
return redirect()->route('daily-quests.index')->with('success', 'چالش روزانه حذف شد.');
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\UserQuest;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class LeagueController extends Controller
{
use BaseApiResponse;
/**
* API 1: User League Stats
*/
public function stats(Request $request)
{
$user = $request->user();
// Dynamic Level Calculation (Every 500 XP = 1 Level)
$level = floor($user->xp / 500) + 1;
$xpToNext = 500 - ($user->xp % 500);
$progress = ($user->xp % 500) / 500;
$winRate = $user->duels_played > 0
? round(($user->duels_won / $user->duels_played) * 100)
: 0;
// Calculate Global Rank Position
$rankPosition = User::where('xp', '>', $user->xp)->count() + 1;
$data = [
'user_id' => $user->id,
'name' => $user->name ?? 'کاربر',
'current_level' => $level,
'rank_name' => $this->getRankName($level),
'total_xp' => $user->xp,
'xp_to_next_level' => $xpToNext,
'progress_percent' => round($progress, 2),
'win_rate_percent' => $winRate,
'total_duels' => $user->duels_played,
'global_rank_position' => $rankPosition
];
return $this->success($data, 'موفق', 'اطلاعات لیگ کاربر دریافت شد');
}
/**
* API 2: Daily Quests
*/
public function quests(Request $request)
{
$user = $request->user();
// Fetch active quests for today using the new relationship
$userQuests = $user->activeQuests()->with('quest')->get();
$formattedQuests = $userQuests->map(function ($uq) {
return [
'id' => $uq->id,
'title' => $uq->quest->title,
'current_progress' => $uq->current_progress,
'required_amount' => $uq->quest->required_amount,
'reward_text' => '+' . $uq->quest->reward_xp . ' XP',
'is_completed' => $uq->is_completed
];
});
$expiresIn = now()->endOfDay()->diffInSeconds(now());
return $this->success([
'expires_in_seconds' => $expiresIn,
'quests' => $formattedQuests
], 'موفق', 'چالش‌های روزانه دریافت شد');
}
/**
* API 3: League Leaderboard
*/
public function leaderboard(Request $request)
{
$currentUser = $request->user();
// Get Top 50 Users
$topUsers = User::orderByDesc('xp')->limit(50)->get(['id', 'name', 'xp']);
$leaderboard = $topUsers->map(function ($u, $index) use ($currentUser) {
return [
'rank' => $index + 1,
'user_id' => $u->id,
'name' => $u->name ?? 'کاربر ناشناس',
'xp' => $u->xp,
'is_me' => $u->id === $currentUser->id
];
});
// Current User Data
$myRank = User::where('xp', '>', $currentUser->xp)->count() + 1;
$myStats = [
'rank' => $myRank,
'user_id' => $currentUser->id,
'name' => $currentUser->name ?? 'شما',
'xp' => $currentUser->xp,
'is_me' => true
];
return $this->success([
'leaderboard' => $leaderboard,
'current_user' => $myStats
], 'موفق', 'جدول رده‌بندی دریافت شد');
}
/**
* API 4: PvP Matchmaking Entry
*/
public function matchmake(Request $request)
{
$ticketId = 'req_' . Str::random(12);
return $this->success([
'matchmaking_ticket_id' => $ticketId,
'status' => 'searching',
'websocket_channel' => 'private-matchmaking.' . $ticketId,
'estimated_wait_seconds' => 15
], 'در حال جستجو', 'به صف انتظار دوئل اضافه شدید');
}
private function getRankName($level)
{
if ($level >= 50) return 'الماس';
if ($level >= 30) return 'طلا';
if ($level >= 10) return 'نقره‌ای';
return 'برنز';
}
}

28
app/Models/DailyQuest.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class DailyQuest extends Model
{
use HasFactory;
protected $fillable = [
'title',
'required_amount',
'reward_xp',
'quest_type',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function userQuests()
{
return $this->hasMany(UserQuest::class, 'daily_quest_id');
}
}

View File

@@ -21,6 +21,9 @@ class User extends Authenticatable
'is_admin', 'is_admin',
'email', 'email',
'password', 'password',
'xp',
'duels_played',
'duels_won',
]; ];
protected $hidden = [ protected $hidden = [
@@ -67,4 +70,25 @@ class User extends Authenticatable
->withPivot('read_at') ->withPivot('read_at')
->withTimestamps(); ->withTimestamps();
} }
// ==========================================
// --- NEW: Gamification Relationships ---
// ==========================================
/**
* Get all quest records for the user.
*/
public function userQuests()
{
return $this->hasMany(UserQuest::class);
}
/**
* Helper relationship to quickly get ONLY active, unexpired quests.
*/
public function activeQuests()
{
return $this->hasMany(UserQuest::class)
->where('expires_at', '>', now());
}
} }

35
app/Models/UserQuest.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UserQuest extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'daily_quest_id',
'current_progress',
'is_completed',
'expires_at',
];
// Casts ensure Laravel automatically converts these to the right data types
protected $casts = [
'is_completed' => 'boolean',
'expires_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function quest()
{
return $this->belongsTo(DailyQuest::class, 'daily_quest_id');
}
}

View File

@@ -0,0 +1,47 @@
@extends('admin.layouts.app')
@section('content')
<div class="row">
@include('admin.layouts.errors')
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h4 class="card-title mb-4">افزودن چالش روزانه جدید</h4>
<form action="{{ route('daily-quests.store') }}" method="post">
@csrf
<div class="mb-4">
<label>عنوان چالش (مثلاً: پیروزی در ۲ دوئل)</label>
<input class="form-control" name="title" type="text" required>
</div>
<div class="mb-4">
<label>نوع چالش</label>
<select name="quest_type" class="form-control" required>
<option value="duel_win">پیروزی در دوئل (PvP)</option>
<option value="answer_tests">پاسخ به تست‌ها</option>
<option value="perfect_score">کسب نمره کامل در آزمون</option>
</select>
</div>
<div class="mb-4">
<label>مقدار مورد نیاز (مثلاً: 2)</label>
<input class="form-control" name="required_amount" type="number" min="1" required>
</div>
<div class="mb-4">
<label>پاداش (XP)</label>
<input class="form-control" name="reward_xp" type="number" min="10" step="10" required>
</div>
<div class="custom-control custom-switch mb-4" dir="ltr">
<input type="checkbox" class="custom-control-input" id="isActiveSwitch" name="is_active"
{{ (isset($daily_quest) && $daily_quest->is_active) || !isset($daily_quest) ? 'checked' : '' }}>
<label class="custom-control-label" for="isActiveSwitch">چالش فعال باشد؟</label>
</div>
<button type="submit" class="btn btn-primary w-100">ثبت چالش</button>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,108 @@
@extends('admin.layouts.app')
@section('content')
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="card-title mb-0">مدیریت چالش‌های روزانه (لیگ)</h4>
<div>
<!-- NEW: Suggestion Modal Trigger Button -->
<button type="button" class="btn btn-warning text-dark font-weight-bold mr-2" data-toggle="modal" data-target="#suggestionsModal">
<i class="mdi mdi-lightbulb-on mr-1"></i> پیشنهاد هوشمند
</button>
<a href="{{ route('daily-quests.create') }}" class="btn btn-success">افزودن چالش دستی</a>
</div>
</div>
@if(session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
<div class="table-responsive">
<table class="table table-bordered dt-responsive nowrap">
<thead>
<tr>
<th>شناسه</th>
<th>عنوان چالش</th>
<th>نوع چالش</th>
<th>مقدار مورد نیاز</th>
<th>پاداش (XP)</th>
<th>وضعیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
@foreach ($quests as $item)
<tr>
<td>{{ $item->id }}</td>
<td>{{ $item->title }}</td>
<td><span class="badge badge-info">{{ $item->quest_type }}</span></td>
<td>{{ $item->required_amount }}</td>
<td><span class="text-success font-weight-bold">+{{ $item->reward_xp }} XP</span></td>
<td>
<span class="badge badge-{{ $item->is_active ? 'success' : 'danger' }}">
{{ $item->is_active ? 'فعال' : 'غیرفعال' }}
</span>
</td>
<td>
<form action="{{ route('daily-quests.destroy', $item->id) }}" method="POST" style="display: inline;">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('آیا از حذف این چالش مطمئن هستید؟')">حذف</button>
</form>
<a href="{{ route('daily-quests.edit', $item->id) }}" class="btn btn-primary btn-sm">ویرایش</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="d-flex justify-content-center mt-3">
{{ $quests->links() }}
</div>
</div>
</div>
</div>
</div>
<!-- NEW: Suggestions Modal -->
<div class="modal fade" id="suggestionsModal" tabindex="-1" role="dialog" aria-labelledby="suggestionsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header bg-warning">
<h5 class="modal-title text-dark" id="suggestionsModalLabel"><i class="mdi mdi-lightbulb-on mr-1"></i> پیشنهادهای از پیش آماده</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body bg-light">
<p class="text-muted mb-4">با کلیک روی دکمه «افزودن»، این چالش فوراً به دیتابیس شما اضافه شده و فعال می‌گردد.</p>
@foreach($suggestions as $suggestion)
<div class="card mb-3 border-warning shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center p-3">
<div>
<h5 class="mb-1 font-size-16">{{ $suggestion['title'] }}</h5>
<span class="text-muted font-size-13">نوع: <strong>{{ $suggestion['quest_type'] }}</strong> | مقدار: <strong>{{ $suggestion['required_amount'] }}</strong> | پاداش: <span class="text-success font-weight-bold">+{{ $suggestion['reward_xp'] }} XP</span></span>
</div>
<!-- Reusing your store route seamlessly -->
<form action="{{ route('daily-quests.store') }}" method="POST" class="m-0">
@csrf
<input type="hidden" name="title" value="{{ $suggestion['title'] }}">
<input type="hidden" name="quest_type" value="{{ $suggestion['quest_type'] }}">
<input type="hidden" name="required_amount" value="{{ $suggestion['required_amount'] }}">
<input type="hidden" name="reward_xp" value="{{ $suggestion['reward_xp'] }}">
<input type="hidden" name="is_active" value="1">
<button type="submit" class="btn btn-sm btn-dark px-3">افزودن +</button>
</form>
</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,60 @@
@extends('admin.layouts.app')
@section('content')
<div class="row">
@include('admin.layouts.errors')
<div class="col-lg-6">
<div class="card">
<div class="card-body">
<h4 class="card-title mb-4">ویرایش چالش روزانه</h4>
<form action="{{ route('daily-quests.update', $daily_quest->id) }}" method="post">
@csrf
@method('PUT')
<div class="mb-4">
<label>عنوان چالش</label>
<input class="form-control" name="title" type="text" value="{{ $daily_quest->title }}"
required>
</div>
<div class="mb-4">
<label>نوع چالش</label>
<select name="quest_type" class="form-control" required>
<option value="duel_win" {{ $daily_quest->quest_type == 'duel_win' ? 'selected' : '' }}>
پیروزی در دوئل (PvP)
</option>
<option
value="answer_tests" {{ $daily_quest->quest_type == 'answer_tests' ? 'selected' : '' }}>
پاسخ به تست‌ها
</option>
<option
value="perfect_score" {{ $daily_quest->quest_type == 'perfect_score' ? 'selected' : '' }}>
کسب نمره کامل در آزمون
</option>
</select>
</div>
<div class="mb-4">
<label>مقدار مورد نیاز</label>
<input class="form-control" name="required_amount" type="number"
value="{{ $daily_quest->required_amount }}" required>
</div>
<div class="mb-4">
<label>پاداش (XP)</label>
<input class="form-control" name="reward_xp" type="number"
value="{{ $daily_quest->reward_xp }}" required>
</div>
<div class="custom-control custom-switch mb-4" dir="ltr">
<input type="checkbox" class="custom-control-input" id="isActiveSwitch" name="is_active"
{{ (isset($daily_quest) && $daily_quest->is_active) || !isset($daily_quest) ? 'checked' : '' }}>
<label class="custom-control-label" for="isActiveSwitch">چالش فعال باشد؟</label>
</div>
<button type="submit" class="btn btn-primary w-100">ویرایش چالش</button>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -67,6 +67,16 @@
</a> </a>
</li> </li>
<li>
<a href="javascript: void(0);" class="has-arrow waves-effect">
<i class="mdi mdi-gamepad-variant"></i>
<span>لیگ و چالش‌ها</span>
</a>
<ul class="sub-menu" aria-expanded="false">
<li><a href="{{ route('daily-quests.index') }}">لیست چالش‌های روزانه</a></li>
<li><a href="{{ route('daily-quests.create') }}">افزودن چالش جدید</a></li>
</ul>
</li>
<li> <li>
<a href="javascript:%20void(0);" class="has-arrow waves-effect"> <a href="javascript:%20void(0);" class="has-arrow waves-effect">

View File

@@ -23,6 +23,7 @@ use App\Http\Controllers\SuggestionController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\api\QuizController; use App\Http\Controllers\api\QuizController;
use App\Http\Controllers\api\PvPQuizController; use App\Http\Controllers\api\PvPQuizController;
use App\Http\Controllers\api\LeagueController;
Route::prefix('v1')->group(function () { Route::prefix('v1')->group(function () {
Route::post('login', [AuthController::class, 'login']); Route::post('login', [AuthController::class, 'login']);
@@ -165,5 +166,12 @@ Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
Route::get('market/gold', [CurrencyController::class, 'goldPrices']); Route::get('market/gold', [CurrencyController::class, 'goldPrices']);
// PvP & League Gamification Routes
Route::prefix('league')->group(function () {
Route::get('/stats', [LeagueController::class, 'stats']);
Route::get('/quests', [LeagueController::class, 'quests']);
Route::get('/leaderboard', [LeagueController::class, 'leaderboard']);
Route::post('/pvp/matchmake', [LeagueController::class, 'matchmake']);
});
}); });

View File

@@ -34,6 +34,7 @@ use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\QuizController; use App\Http\Controllers\Admin\QuizController;
use App\Http\Controllers\Admin\QuizCategoryController; use App\Http\Controllers\Admin\QuizCategoryController;
use App\Http\Controllers\Admin\QuizSubjectController; use App\Http\Controllers\Admin\QuizSubjectController;
use App\Http\Controllers\Admin\DailyQuestController;
Route::get('payment/callback', [PayController::class, 'callback']); Route::get('payment/callback', [PayController::class, 'callback']);
@@ -89,5 +90,9 @@ Route::middleware(['auth', config('jetstream.auth_session')])->group(function ()
Route::post('/users/revoke-subscription', [\App\Http\Controllers\Admin\UsersController::class, 'revokeSubscription']) Route::post('/users/revoke-subscription', [\App\Http\Controllers\Admin\UsersController::class, 'revokeSubscription'])
->name('admin.subscriptions.revoke'); ->name('admin.subscriptions.revoke');
Route::prefix('admin')->middleware(['auth', 'admin'])->group(function () {
// ... your other admin routes
Route::resource('daily-quests', DailyQuestController::class)->except(['show']);
});
}); });