فهرست منبع

Merge pull request #123 from ControlPanel-gg/development

Development
AVMG 4 سال پیش
والد
کامیت
af508ded4b

+ 1 - 1
app/Http/Controllers/Admin/UserController.php

@@ -78,7 +78,7 @@ class UserController extends Controller
             "name" => "required|string|min:4|max:30",
             "pterodactyl_id" => "required|numeric|unique:users,pterodactyl_id,{$user->id}",
             "email" => "required|string|email",
-            "credits" => "required|numeric|min:0|max:999999",
+            "credits" => "required|numeric|min:0|max:99999999",
             "server_limit" => "required|numeric|min:0|max:1000000",
             "role" => Rule::in(['admin', 'mod', 'client', 'member']),
         ]);

+ 196 - 0
app/Http/Controllers/Admin/VoucherController.php

@@ -0,0 +1,196 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Http\Controllers\Controller;
+use App\Models\Voucher;
+use Illuminate\Contracts\Foundation\Application;
+use Illuminate\Contracts\View\Factory;
+use Illuminate\Contracts\View\View;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+use Illuminate\Validation\ValidationException;
+
+class VoucherController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return Application|Factory|View
+     */
+    public function index()
+    {
+        return view('admin.vouchers.index');
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     *
+     * @return Application|Factory|View
+     */
+    public function create()
+    {
+        return view('admin.vouchers.create');
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param Request $request
+     * @return RedirectResponse
+     */
+    public function store(Request $request)
+    {
+        $request->validate([
+            'memo'       => 'nullable|string|max:191',
+            'code'       => 'required|string|alpha_dash|max:36|min:4',
+            'uses'       => 'required|numeric|max:2147483647|min:1',
+            'credits'    => 'required|numeric|between:0,99999999',
+            'expires_at' => ['nullable','date_format:d-m-Y','after:today',"before:10 years"],
+        ]);
+
+        Voucher::create($request->except('_token'));
+
+        return redirect()->route('admin.vouchers.index')->with('success', 'voucher has been created!');
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param Voucher $voucher
+     * @return Response
+     */
+    public function show(Voucher $voucher)
+    {
+        //
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     *
+     * @param Voucher $voucher
+     * @return Application|Factory|View
+     */
+    public function edit(Voucher $voucher)
+    {
+        return view('admin.vouchers.edit' , [
+            'voucher' => $voucher
+        ]);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param Request $request
+     * @param Voucher $voucher
+     * @return RedirectResponse
+     */
+    public function update(Request $request, Voucher $voucher)
+    {
+        $request->validate([
+            'memo'       => 'nullable|string|max:191',
+            'code'       => 'required|string|alpha_dash|max:36|min:4',
+            'uses'       => 'required|numeric|max:2147483647|min:1',
+            'credits'    => 'required|numeric|between:0,99999999',
+            'expires_at' => ['nullable','date_format:d-m-Y','after:today',"before:10 years"],
+        ]);
+
+        $voucher->update($request->except('_token'));
+
+        return redirect()->route('admin.vouchers.index')->with('success', 'voucher has been updated!');
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param Voucher $voucher
+     * @return RedirectResponse
+     */
+    public function destroy(Voucher $voucher)
+    {
+        $voucher->delete();
+        return redirect()->back()->with('success', 'voucher has been removed!');
+    }
+
+    /**
+     * @param Request $request
+     * @return JsonResponse
+     * @throws ValidationException
+     */
+    public function redeem(Request $request)
+    {
+        #general validations
+        $request->validate([
+            'code' => 'required|exists:vouchers,code'
+        ]);
+
+        #get voucher by code
+        $voucher = Voucher::where('code' , '=' , $request->input('code'))->firstOrFail();
+
+        #extra validations
+        if ($voucher->getStatus() == 'USES_LIMIT_REACHED') throw ValidationException::withMessages([
+            'code' => 'This voucher has reached the maximum amount of uses'
+        ]);
+
+        if ($voucher->getStatus() == 'EXPIRED') throw ValidationException::withMessages([
+            'code' => 'This voucher has expired'
+        ]);
+
+        if (!$request->user()->vouchers()->where('id' , '=' , $voucher->id)->get()->isEmpty()) throw ValidationException::withMessages([
+            'code' => 'You already redeemed this voucher code'
+        ]);
+
+        if ($request->user()->credits + $voucher->credits >= 99999999) throw ValidationException::withMessages([
+            'code' => "You can't redeem this voucher because you would exceed the credit limit"
+        ]);
+
+        #redeem voucher
+        $voucher->redeem($request->user());
+
+        return response()->json([
+            'success' => "{$voucher->credits} credits have been added to your balance!"
+        ]);
+    }
+
+    public function dataTable()
+    {
+        $query = Voucher::query();
+
+        return datatables($query)
+            ->addColumn('actions', function (Voucher $voucher) {
+                return '
+                            <a data-content="Edit" data-toggle="popover" data-trigger="hover" data-placement="top" href="' . route('admin.vouchers.edit', $voucher->id) . '" class="btn btn-sm btn-info mr-1"><i class="fas fa-pen"></i></a>
+
+                           <form class="d-inline" onsubmit="return submitResult();" method="post" action="' . route('admin.vouchers.destroy', $voucher->id) . '">
+                            ' . csrf_field() . '
+                            ' . method_field("DELETE") . '
+                           <button data-content="Delete" data-toggle="popover" data-trigger="hover" data-placement="top" class="btn btn-sm btn-danger mr-1"><i class="fas fa-trash"></i></button>
+                       </form>
+                ';
+            })
+            ->addColumn('status', function (Voucher $voucher) {
+                $color = 'success';
+                if ($voucher->getStatus() != 'VALID') $color = 'danger';
+                return '<span class="badge badge-' . $color . '">' . $voucher->getStatus() . '</span>';
+            })
+            ->editColumn('uses', function (Voucher $voucher) {
+                $userCount = $voucher->users()->count();
+                return "{$userCount} / {$voucher->uses}";
+            })
+            ->editColumn('credits', function (Voucher $voucher) {
+                return number_format($voucher->credits, 2, '.', '');
+            })
+            ->editColumn('expires_at', function (Voucher $voucher) {
+                if (!$voucher->expires_at) return "";
+                return $voucher->expires_at ? $voucher->expires_at->diffForHumans() : '';
+            })
+            ->editColumn('code', function (Voucher $voucher) {
+                return "<code>{$voucher->code}</code>";
+            })
+            ->rawColumns(['actions', 'code', 'status'])
+            ->make();
+    }
+
+}

+ 31 - 0
app/Http/Controllers/Auth/LoginController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
 use App\Http\Controllers\Controller;
 use App\Providers\RouteServiceProvider;
 use Illuminate\Foundation\Auth\AuthenticatesUsers;
+use Illuminate\Http\Request;
 
 class LoginController extends Controller
 {
@@ -37,4 +38,34 @@ class LoginController extends Controller
     {
         $this->middleware('guest')->except('logout');
     }
+
+    public function login(Request $request)
+    {
+        $request->validate([
+            $this->username()      => 'required|string',
+            'password'             => 'required|string',
+            'g-recaptcha-response' => ['required','recaptcha'],
+        ]);
+
+        // If the class is using the ThrottlesLogins trait, we can automatically throttle
+        // the login attempts for this application. We'll key this by the username and
+        // the IP address of the client making these requests into this application.
+        if (method_exists($this, 'hasTooManyLoginAttempts') &&
+            $this->hasTooManyLoginAttempts($request)) {
+            $this->fireLockoutEvent($request);
+
+            return $this->sendLockoutResponse($request);
+        }
+
+        if ($this->attemptLogin($request)) {
+            return $this->sendLoginResponse($request);
+        }
+
+        // If the login attempt was unsuccessful we will increment the number of attempts
+        // to login and redirect the user back to the login form. Of course, when this
+        // user surpasses their maximum number of attempts they will get locked out.
+        $this->incrementLoginAttempts($request);
+
+        return $this->sendFailedLoginResponse($request);
+    }
 }

+ 2 - 1
app/Http/Controllers/ProfileController.php

@@ -20,7 +20,8 @@ class ProfileController extends Controller
         return view('profile.index')->with([
             'user' => Auth::user(),
             'credits_reward_after_verify_discord' => Configuration::getValueByKey('CREDITS_REWARD_AFTER_VERIFY_DISCORD'),
-            'discord_verify_command' => Configuration::getValueByKey('DISCORD_VERIFY_COMMAND')
+            'force_email_verification' => Configuration::getValueByKey('FORCE_EMAIL_VERIFICATION'),
+            'force_discord_verification' => Configuration::getValueByKey('FORCE_DISCORD_VERIFICATION'),
         ]);
     }
 

+ 49 - 0
app/Models/User.php

@@ -7,17 +7,30 @@ use App\Notifications\Auth\QueuedVerifyEmail;
 use App\Notifications\WelcomeMessage;
 use Illuminate\Contracts\Auth\MustVerifyEmail;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;
 use Spatie\Activitylog\Traits\CausesActivity;
 use Spatie\Activitylog\Traits\LogsActivity;
 
+/**
+ * Class User
+ * @package App\Models
+ */
 class User extends Authenticatable implements MustVerifyEmail
 {
     use HasFactory, Notifiable, LogsActivity, CausesActivity;
 
+    /**
+     * @var string[]
+     */
     protected static $logAttributes = ['name', 'email'];
 
+    /**
+     * @var string[]
+     */
     protected static $ignoreChangedAttributes = [
         'remember_token',
         'credits',
@@ -68,6 +81,9 @@ class User extends Authenticatable implements MustVerifyEmail
         'last_seen' => 'datetime',
     ];
 
+    /**
+     *
+     */
     public static function boot()
     {
         parent::boot();
@@ -89,24 +105,38 @@ class User extends Authenticatable implements MustVerifyEmail
                 }
             });
 
+            $user->vouchers()->detach();
+
             Pterodactyl::client()->delete("/application/users/{$user->pterodactyl_id}");
         });
     }
 
+    /**
+     *
+     */
     public function sendEmailVerificationNotification()
     {
         $this->notify(new QueuedVerifyEmail);
     }
 
+    /**
+     * @return string
+     */
     public function credits()
     {
         return number_format($this->credits, 2, '.', '');
     }
 
+    /**
+     * @return string
+     */
     public function getAvatar(){
         return "https://www.gravatar.com/avatar/" . md5(strtolower(trim($this->email)));
     }
 
+    /**
+     * @return string
+     */
     public function creditUsage()
     {
         $usage = 0;
@@ -118,6 +148,9 @@ class User extends Authenticatable implements MustVerifyEmail
         return number_format($usage, 2, '.', '');
     }
 
+    /**
+     * @return array|string|string[]
+     */
     public function getVerifiedStatus(){
         $status = '';
         if ($this->hasVerifiedEmail()) $status .= 'email ';
@@ -126,15 +159,31 @@ class User extends Authenticatable implements MustVerifyEmail
         return $status;
     }
 
+    /**
+     * @return BelongsToMany
+     */
+    public function vouchers(){
+        return $this->belongsToMany(Voucher::class);
+    }
+
+    /**
+     * @return HasOne
+     */
     public function discordUser(){
         return $this->hasOne(DiscordUser::class);
     }
 
+    /**
+     * @return HasMany
+     */
     public function servers()
     {
         return $this->hasMany(Server::class);
     }
 
+    /**
+     * @return HasMany
+     */
     public function payments()
     {
         return $this->hasMany(Payment::class);

+ 98 - 0
app/Models/Voucher.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace App\Models;
+
+use Exception;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Spatie\Activitylog\Traits\LogsActivity;
+
+/**
+ * Class Voucher
+ * @package App\Models
+ */
+class Voucher extends Model
+{
+    use HasFactory, LogsActivity;
+
+    /**
+     * @var string[]
+     */
+    protected $fillable = [
+        'memo',
+        'code',
+        'credits',
+        'uses',
+        'expires_at',
+    ];
+
+    protected $dates = [
+        'expires_at'
+    ];
+
+    /**
+     *
+     */
+    public static function boot()
+    {
+        parent::boot();
+
+        static::deleting(function (Voucher $voucher) {
+            $voucher->users()->detach();
+        });
+    }
+
+    /**
+     * @return BelongsToMany
+     */
+    public function users()
+    {
+        return $this->belongsToMany(User::class);
+    }
+
+    /**
+     * @return string
+     */
+    public function getStatus()
+    {
+        if ($this->users()->count() >= $this->uses) return 'USES_LIMIT_REACHED';
+        if (!is_null($this->expires_at)) {
+            if ($this->expires_at->isPast()) return 'EXPIRED';
+        }
+
+        return 'VALID';
+    }
+
+    /**
+     * @param User $user
+     * @return float
+     * @throws Exception
+     */
+    public function redeem(User $user)
+    {
+        try {
+            $user->increment('credits', $this->credits);
+            $this->users()->attach($user);
+            $this->logRedeem($user);
+        } catch (Exception $exception) {
+            throw $exception;
+        }
+
+        return $this->credits;
+    }
+
+    /**
+     * @param User $user
+     * @return null
+     */
+    private function logRedeem(User $user)
+    {
+        activity()
+            ->performedOn($this)
+            ->causedBy($user)
+            ->log('redeemed');
+
+        return null;
+    }
+}

+ 34 - 0
database/factories/VoucherFactory.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\voucher;
+use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Str;
+
+class VoucherFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = voucher::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'memo'       => $this->faker->word(),
+            'code'       => Str::random(36),
+            'credits'    => $this->faker->numberBetween(100, 1000),
+            'uses'       => $this->faker->numberBetween(1, 1000),
+            'expires_at' => now()->addDays($this->faker->numberBetween(1, 90))->format('d-m-Y')
+        ];
+
+    }
+}

+ 36 - 0
database/migrations/2021_07_09_190453_create_vouchers_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateVouchersTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('vouchers', function (Blueprint $table) {
+            $table->id();
+            $table->string('code', 36)->unique();
+            $table->string('memo')->nullable();
+            $table->unsignedFloat('credits', 10);
+            $table->unsignedInteger('uses')->default(1);
+            $table->timestamp('expires_at')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('vouchers');
+    }
+}

+ 32 - 0
database/migrations/2021_07_09_191913_create_user_voucher_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateUserVoucherTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('user_voucher', function (Blueprint $table) {
+            $table->foreignId('user_id')->constrained();
+            $table->foreignId('voucher_id')->constrained();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('user_voucher');
+    }
+}

+ 32 - 0
database/migrations/2021_07_10_062140_update_credits_to_users_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class UpdateCreditsToUsersTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->unsignedFloat('credits', 10)->change();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->unsignedFloat('credits')->change();
+        });
+    }
+}

+ 3 - 0
resources/views/admin/activitylogs/index.blade.php

@@ -78,6 +78,9 @@
                                                 @case('created')
                                                 <small><i class="fas text-success fa-plus mr-2"></i></small>
                                                 @break
+                                                @case('redeemed')
+                                                <small><i class="fas text-success fa-money-check-alt mr-2"></i></small>
+                                                @break
                                                 @case('deleted')
                                                 <small><i class="fas text-danger fa-times mr-2"></i></small>
                                                 @break

+ 1 - 1
resources/views/admin/users/edit.blade.php

@@ -72,7 +72,7 @@
                                 <div class="form-group">
                                     <label for="credits">Credits</label>
                                     <input value="{{$user->credits}}" id="credits" name="credits" step="any" min="0"
-                                           max="1000000"
+                                           max="99999999"
                                            type="number" class="form-control @error('credits') is-invalid @enderror"
                                            required="required">
                                     @error('credits')

+ 181 - 0
resources/views/admin/vouchers/create.blade.php

@@ -0,0 +1,181 @@
+@extends('layouts.main')
+
+@section('content')
+    <!-- CONTENT HEADER -->
+    <section class="content-header">
+        <div class="container-fluid">
+            <div class="row mb-2">
+                <div class="col-sm-6">
+                    <h1>Vouchers</h1>
+                </div>
+                <div class="col-sm-6">
+                    <ol class="breadcrumb float-sm-right">
+                        <li class="breadcrumb-item"><a href="{{route('home')}}">Dashboard</a></li>
+                        <li class="breadcrumb-item"><a href="{{route('admin.vouchers.index')}}">Vouchers</a></li>
+                        <li class="breadcrumb-item"><a class="text-muted" href="{{route('admin.products.create')}}">Create</a>
+                        </li>
+                    </ol>
+                </div>
+            </div>
+        </div>
+    </section>
+    <!-- END CONTENT HEADER -->
+
+    <!-- MAIN CONTENT -->
+    <section class="content">
+        <div class="container-fluid">
+
+            <div class="row">
+                <div class="col-lg-6">
+                    <div class="card">
+                        <div class="card-header">
+                            <h5 class="card-title">
+                                <i class="fas fa-money-check-alt mr-2"></i>Voucher details
+                            </h5>
+                        </div>
+                        <div class="card-body">
+                            <form action="{{route('admin.vouchers.store')}}" method="POST">
+                                @csrf
+
+                                <div class="form-group">
+                                    <label for="memo">Memo <i data-toggle="popover" data-trigger="hover" data-content="Only admins can see this" class="fas fa-info-circle"></i></label>
+                                    <input value="{{old('memo')}}" placeholder="Summer break voucher" id="memo"
+                                           name="memo" type="text"
+                                           class="form-control @error('memo') is-invalid @enderror">
+                                    @error('memo')
+                                    <div class="text-danger">
+                                        {{$message}}
+                                    </div>
+                                    @enderror
+                                </div>
+
+                                <div class="form-group">
+                                    <label for="credits">* Credits</label>
+                                    <input value="{{old('credits')}}" placeholder="500" id="credits"
+                                           name="credits" type="number" step="any" min="0"
+                                           max="99999999"
+                                           class="form-control @error('credits') is-invalid @enderror">
+                                    @error('credits')
+                                    <div class="text-danger">
+                                        {{$message}}
+                                    </div>
+                                    @enderror
+                                </div>
+
+
+                                <div class="form-group">
+                                    <label for="code">* Code</label>
+                                    <div class="input-group">
+                                        <input value="{{old('code')}}" placeholder="SUMMER" id="code" name="code"
+                                               type="text"
+                                               class="form-control @error('code') is-invalid @enderror"
+                                               required="required">
+                                        <div class="input-group-append">
+                                            <button class="btn btn-info" onclick="setRandomCode()" type="button">
+                                                Random
+                                            </button>
+                                        </div>
+                                    </div>
+                                    @error('code')
+                                    <div class="text-danger">
+                                        {{$message}}
+                                    </div>
+                                    @enderror
+                                </div>
+
+                                <div class="form-group">
+                                    <label for="uses">* Uses<i data-toggle="popover" data-trigger="hover" data-content="A voucher can only be used one time per user. Uses specifies the number of different users that can use this voucher." class="fas fa-info-circle"></i></label>
+                                    <div class="input-group">
+                                        <input value="{{old('uses') ?? 1}}" id="uses" min="1" max="2147483647"
+                                               name="uses" type="number"
+                                               class="form-control @error('uses') is-invalid @enderror"
+                                               required="required">
+                                        <div class="input-group-append">
+                                            <button class="btn btn-info" onclick="setMaxUses()" type="button">Max
+                                            </button>
+                                        </div>
+                                    </div>
+                                    @error('uses')
+                                    <div class="text-danger">
+                                        {{$message}}
+                                    </div>
+                                    @enderror
+                                </div>
+
+                                <div class="form-group mb-3">
+                                    <label for="expires_at">Expires at</label>
+                                    <div class="input-group date" id="expires_at" data-target-input="nearest">
+                                        <input value="{{old('expires_at')}}" name="expires_at" placeholder="dd-mm-yyyy" type="text" class="form-control @error('expires_at') is-invalid @enderror datetimepicker-input" data-target="#expires_at"/>
+                                        <div class="input-group-append" data-target="#expires_at" data-toggle="datetimepicker">
+                                            <div class="input-group-text"><i class="fa fa-calendar"></i></div>
+                                        </div>
+                                    </div>
+                                    @error('expires_at')
+                                    <div class="text-danger">
+                                        {{$message}}
+                                    </div>
+                                    @enderror
+                                </div>
+
+                                <div class="form-group text-right">
+                                    <button type="submit" class="btn btn-primary">
+                                        Submit
+                                    </button>
+                                </div>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <i class="fas"></i>
+
+        </div>
+    </section>
+    <!-- END CONTENT -->
+
+
+    <script>
+        document.addEventListener('DOMContentLoaded', (event) => {
+            $('#expires_at').datetimepicker({
+                format : 'DD-MM-yyyy',
+                icons: {
+                    time: 'far fa-clock',
+                    date: 'far fa-calendar',
+                    up: 'fas fa-arrow-up',
+                    down: 'fas fa-arrow-down',
+                    previous: 'fas fa-chevron-left',
+                    next: 'fas fa-chevron-right',
+                    today: 'fas fa-calendar-check',
+                    clear: 'far fa-trash-alt',
+                    close: 'far fa-times-circle'
+                }
+            });
+        })
+
+        function setMaxUses() {
+            let element = document.getElementById('uses')
+            element.value = element.max;
+            console.log(element.max)
+        }
+
+
+        function setRandomCode() {
+            let element = document.getElementById('code')
+            element.value = getRandomCode(36)
+        }
+
+        function getRandomCode(length) {
+            let result = '';
+            let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-';
+            let charactersLength = characters.length;
+            for (let i = 0; i < length; i++) {
+                result += characters.charAt(Math.floor(Math.random() *
+                    charactersLength));
+            }
+            return result;
+        }
+    </script>
+
+
+@endsection

+ 186 - 0
resources/views/admin/vouchers/edit.blade.php

@@ -0,0 +1,186 @@
+@extends('layouts.main')
+
+@section('content')
+    <!-- CONTENT HEADER -->
+    <section class="content-header">
+        <div class="container-fluid">
+            <div class="row mb-2">
+                <div class="col-sm-6">
+                    <h1>Vouchers</h1>
+                </div>
+                <div class="col-sm-6">
+                    <ol class="breadcrumb float-sm-right">
+                        <li class="breadcrumb-item"><a href="{{route('home')}}">Dashboard</a></li>
+                        <li class="breadcrumb-item"><a href="{{route('admin.vouchers.index')}}">Vouchers</a></li>
+                        <li class="breadcrumb-item"><a class="text-muted"
+                                                       href="{{route('admin.products.edit' , $voucher->id)}}">Edit</a>
+                        </li>
+                    </ol>
+                </div>
+            </div>
+        </div>
+    </section>
+    <!-- END CONTENT HEADER -->
+
+    <!-- MAIN CONTENT -->
+    <section class="content">
+        <div class="container-fluid">
+
+            <div class="row">
+                <div class="col-lg-6">
+                    <div class="card">
+                        <div class="card-header">
+                            <h5 class="card-title">
+                                <i class="fas fa-money-check-alt mr-2"></i>Voucher details
+                            </h5>
+                        </div>
+                        <div class="card-body">
+                            <form action="{{route('admin.vouchers.update' , $voucher->id)}}" method="POST">
+                                @csrf
+                                @method('PATCH')
+
+                                <div class="form-group">
+                                    <label for="memo">Memo <i data-toggle="popover" data-trigger="hover"
+                                                              data-content="Only admins can see this"
+                                                              class="fas fa-info-circle"></i></label>
+                                    <input value="{{ $voucher->memo }}" placeholder="Summer break voucher" id="memo"
+                                           name="memo" type="text"
+                                           class="form-control @error('memo') is-invalid @enderror">
+                                    @error('memo')
+                                    <div class="text-danger">
+                                        {{$message}}
+                                    </div>
+                                    @enderror
+                                </div>
+
+                                <div class="form-group">
+                                    <label for="credits">Credits *</label>
+                                    <input value="{{$voucher->credits}}" placeholder="500" id="credits"
+                                           name="credits" type="number" step="any" min="0"
+                                           max="99999999"
+                                           class="form-control @error('credits') is-invalid @enderror">
+                                    @error('credits')
+                                    <div class="text-danger">
+                                        {{$message}}
+                                    </div>
+                                    @enderror
+                                </div>
+
+
+                                <div class="form-group">
+                                    <label for="code">Code *</label>
+                                    <div class="input-group">
+                                        <input value="{{$voucher->code}}" placeholder="SUMMER" id="code" name="code"
+                                               type="text"
+                                               class="form-control @error('code') is-invalid @enderror"
+                                               required="required">
+                                        <div class="input-group-append">
+                                            <button class="btn btn-info" onclick="setRandomCode()" type="button">
+                                                Random
+                                            </button>
+                                        </div>
+                                    </div>
+                                    @error('code')
+                                    <div class="text-danger">
+                                        {{$message}}
+                                    </div>
+                                    @enderror
+                                </div>
+
+                                <div class="form-group">
+                                    <label for="uses">Uses * <i data-toggle="popover" data-trigger="hover"
+                                                                data-content="A voucher can only be used one time per user. Uses specifies the number of different users that can use this voucher."
+                                                                class="fas fa-info-circle"></i></label>
+                                    <div class="input-group">
+                                        <input value="{{$voucher->uses}}" id="uses" min="1" max="2147483647"
+                                               name="uses" type="number"
+                                               class="form-control @error('uses') is-invalid @enderror"
+                                               required="required">
+                                        <div class="input-group-append">
+                                            <button class="btn btn-info" onclick="setMaxUses()" type="button">Max
+                                            </button>
+                                        </div>
+                                    </div>
+                                    @error('uses')
+                                    <div class="text-danger">
+                                        {{$message}}
+                                    </div>
+                                    @enderror
+                                </div>
+
+                                <div class="form-group mb-3">
+                                    <label for="expires_at">Expires at</label>
+                                    <div class="input-group date" id="expires_at" data-target-input="nearest">
+                                        <input value="{{$voucher->expires_at ? $voucher->expires_at->format('d-m-Y') : ''}}" name="expires_at" placeholder="dd-mm-yyyy" type="text" class="form-control @error('expires_at') is-invalid @enderror datetimepicker-input" data-target="#expires_at"/>
+                                        <div class="input-group-append" data-target="#expires_at" data-toggle="datetimepicker">
+                                            <div class="input-group-text"><i class="fa fa-calendar"></i></div>
+                                        </div>
+                                    </div>
+                                    @error('expires_at')
+                                    <div class="text-danger">
+                                        {{$message}}
+                                    </div>
+                                    @enderror
+                                </div>
+
+
+                                <div class="form-group text-right">
+                                    <button type="submit" class="btn btn-primary">
+                                        Submit
+                                    </button>
+                                </div>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+        </div>
+    </section>
+    <!-- END CONTENT -->
+
+
+    <script>
+        document.addEventListener('DOMContentLoaded', (event) => {
+            $('#expires_at').datetimepicker({
+                format : 'DD-MM-yyyy',
+                icons: {
+                    time: 'far fa-clock',
+                    date: 'far fa-calendar',
+                    up: 'fas fa-arrow-up',
+                    down: 'fas fa-arrow-down',
+                    previous: 'fas fa-chevron-left',
+                    next: 'fas fa-chevron-right',
+                    today: 'fas fa-calendar-check',
+                    clear: 'far fa-trash-alt',
+                    close: 'far fa-times-circle'
+                }
+            });
+        })
+
+        function setMaxUses() {
+            let element = document.getElementById('uses')
+            element.value = element.max;
+            console.log(element.max)
+        }
+
+
+        function setRandomCode() {
+            let element = document.getElementById('code')
+            element.value = getRandomCode(36)
+        }
+
+        function getRandomCode(length) {
+            let result = '';
+            let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-';
+            let charactersLength = characters.length;
+            for (let i = 0; i < length; i++) {
+                result += characters.charAt(Math.floor(Math.random() *
+                    charactersLength));
+            }
+            return result;
+        }
+    </script>
+
+
+@endsection

+ 94 - 0
resources/views/admin/vouchers/index.blade.php

@@ -0,0 +1,94 @@
+@extends('layouts.main')
+
+@section('content')
+    <!-- CONTENT HEADER -->
+    <section class="content-header">
+        <div class="container-fluid">
+            <div class="row mb-2">
+                <div class="col-sm-6">
+                    <h1>Vouchers</h1>
+                </div>
+                <div class="col-sm-6">
+                    <ol class="breadcrumb float-sm-right">
+                        <li class="breadcrumb-item"><a href="{{route('home')}}">Dashboard</a></li>
+                        <li class="breadcrumb-item"><a class="text-muted"
+                                                       href="{{route('admin.vouchers.index')}}">Vouchers</a></li>
+                    </ol>
+                </div>
+            </div>
+        </div>
+    </section>
+    <!-- END CONTENT HEADER -->
+
+    <!-- MAIN CONTENT -->
+    <section class="content">
+        <div class="container-fluid">
+
+            <div class="card">
+
+                <div class="card-header">
+                    <div class="d-flex justify-content-between">
+                        <h5 class="card-title"><i class="fas fa-money-check-alt mr-2"></i>Vouchers</h5>
+                        <a href="{{route('admin.vouchers.create')}}" class="btn btn-sm btn-primary"><i
+                                class="fas fa-plus mr-1"></i>Create new</a>
+                    </div>
+                </div>
+
+                <div class="card-body table-responsive">
+
+                    <table id="datatable" class="table table-striped">
+                        <thead>
+                        <tr>
+                            <th>Status</th>
+                            <th>Code</th>
+                            <th>Memo</th>
+                            <th>Credits</th>
+                            <th>Used / Uses</th>
+                            <th>Expires</th>
+                            <th></th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        </tbody>
+                    </table>
+
+                </div>
+            </div>
+
+
+        </div>
+        <!-- END CUSTOM CONTENT -->
+
+    </section>
+    <!-- END CONTENT -->
+
+    <script>
+        function submitResult() {
+            return confirm("Are you sure you wish to delete?") !== false;
+        }
+
+        document.addEventListener("DOMContentLoaded", function () {
+            $('#datatable').DataTable({
+                processing: true,
+                serverSide: true,
+                stateSave: true,
+                ajax: "{{route('admin.vouchers.datatable')}}",
+                columns: [
+                    {data: 'status'},
+                    {data: 'code'},
+                    {data: 'memo'},
+                    {data: 'credits'},
+                    {data: 'uses'},
+                    {data: 'expires_at'},
+                    {data: 'actions', sortable: false},
+                ],
+                fnDrawCallback: function( oSettings ) {
+                    $('[data-toggle="popover"]').popover();
+                }
+            });
+        });
+    </script>
+
+
+
+@endsection

+ 240 - 0
resources/views/admin/vouchers/show.blade.php

@@ -0,0 +1,240 @@
+@extends('layouts.main')
+
+@section('content')
+    <!-- CONTENT HEADER -->
+    <section class="content-header">
+        <div class="container-fluid">
+            <div class="row mb-2">
+                <div class="col-sm-6">
+                    <h1>Products</h1>
+                </div>
+                <div class="col-sm-6">
+                    <ol class="breadcrumb float-sm-right">
+                        <li class="breadcrumb-item"><a href="{{route('home')}}">Dashboard</a></li>
+                        <li class="breadcrumb-item"><a href="{{route('admin.users.index')}}">Products</a></li>
+                        <li class="breadcrumb-item"><a class="text-muted"
+                                                       href="{{route('admin.products.show' , $product->id)}}">Show</a>
+                        </li>
+                    </ol>
+                </div>
+            </div>
+        </div>
+    </section>
+    <!-- END CONTENT HEADER -->
+
+    <!-- MAIN CONTENT -->
+    <section class="content">
+        <div class="container-fluid">
+
+            <div class="card">
+                <div class="card-header d-flex justify-content-between">
+                    <h5 class="card-title"><i class="fas fa-sliders-h mr-2"></i>Product</h5>
+                    <div class="ml-auto">
+                        <a data-content="Edit" data-trigger="hover" data-toggle="tooltip" href="{{ route('admin.products.edit', $product->id) }}" class="btn btn-sm btn-info mr-1"><i class="fas fa-pen"></i></a>
+                        <form class="d-inline" onsubmit="return submitResult();" method="post" action="{{ route('admin.products.destroy', $product->id) }}">
+                            {{ csrf_field() }}
+                            {{ method_field("DELETE") }}
+                            <button data-content="Delete" data-trigger="hover" data-toggle="tooltip" class="btn btn-sm btn-danger mr-1"><i class="fas fa-trash"></i></button>
+                        </form>
+                    </div>
+                </div>
+                <div class="card-body">
+                    <div class="row">
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>ID</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->id}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>Name</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->name}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>Price</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           <i class="fas fa-coins mr-1"></i>{{$product->price}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>Memory</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->memory}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>CPU</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->cpu}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>Swap</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->swap}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>Disk</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->disk}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>IO</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->io}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>Databases</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->databases}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>Allocations</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->allocations}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>Created At</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->created_at ? $product->created_at->diffForHumans() : ''}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>Description</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span class="d-inline-block text-truncate">
+                                           {{$product->description}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+
+                        <div class="col-lg-6">
+                            <div class="row">
+                                <div class="col-lg-4">
+                                    <label>Updated At</label>
+                                </div>
+                                <div class="col-lg-8">
+                                       <span style="max-width: 250px;" class="d-inline-block text-truncate">
+                                           {{$product->updated_at ? $product->updated_at->diffForHumans() : ''}}
+                                       </span>
+                                </div>
+                            </div>
+                        </div>
+
+                    </div>
+                </div>
+            </div>
+
+            <div class="card">
+                <div class="card-header">
+                    <h5 class="card-title"><i class="fas fa-server mr-2"></i>Servers</h5>
+                </div>
+                <div class="card-body table-responsive">
+
+                    @include('admin.servers.table' , ['filter' => '?product=' . $product->id])
+
+                </div>
+            </div>
+
+
+        </div>
+        <!-- END CUSTOM CONTENT -->
+        </div>
+    </section>
+    <!-- END CONTENT -->
+
+
+
+@endsection

+ 9 - 0
resources/views/auth/login.blade.php

@@ -54,6 +54,15 @@
                         @enderror
                     </div>
 
+                    <div class="input-group mb-3">
+                        {!! htmlFormSnippet() !!}
+                        @error('g-recaptcha-response')
+                        <span class="text-danger" role="alert">
+                                <small><strong>{{ $message }}</strong></small>
+                            </span>
+                        @enderror
+                    </div>
+
                     <div class="row">
                         <div class="col-8">
                             <div class="icheck-primary">

+ 3 - 0
resources/views/home.blade.php

@@ -117,6 +117,9 @@
                                                 @case('created')
                                                     <small><i class="fas text-success fa-plus mr-2"></i></small>
                                                 @break
+                                                @case('redeemed')
+                                                <small><i class="fas text-success fa-money-check-alt mr-2"></i></small>
+                                                @break
                                                 @case('deleted')
                                                     <small><i class="fas text-danger fa-times mr-2"></i></small>
                                                 @break

+ 51 - 17
resources/views/layouts/main.blade.php

@@ -16,6 +16,9 @@
     {{--  summernote --}}
     <link rel="stylesheet" href="{{asset('plugins/summernote/summernote-bs4.min.css')}}">
 
+    {{--  datetimepicker --}}
+    <link rel="stylesheet" href="{{asset('plugins/tempusdominus-bootstrap-4/css/tempusdominus-bootstrap-4.min.css')}}">
+
     <link rel="stylesheet" href="{{asset('css/app.css')}}">
     <link rel="preload" href="{{asset('plugins/fontawesome-free/css/all.min.css')}}" as="style"
           onload="this.onload=null;this.rel='stylesheet'">
@@ -96,6 +99,11 @@
                             Log back in
                         </a>
                     @endif
+                    <a class="dropdown-item" data-toggle="modal" data-target="#redeemVoucherModal"
+                       href="javascript:void(0)">
+                        <i class="fas fa-money-check-alt fa-sm fa-fw mr-2 text-gray-400"></i>
+                        Redeem code
+                    </a>
                     <div class="dropdown-divider"></div>
                     <form method="post" action="{{route('logout')}}">
                         @csrf
@@ -146,13 +154,15 @@
                         </a>
                     </li>
 
-                    <li class="nav-item">
-                        <a href="{{route('store.index')}}"
-                           class="nav-link @if(Request::routeIs('store.*') || Request::routeIs('checkout')) active @endif">
-                            <i class="nav-icon fa fa-coins"></i>
-                            <p>Store</p>
-                        </a>
-                    </li>
+                    @if(env('PAYPAL_SECRET') && env('PAYPAL_CLIENT_ID') || env('APP_ENV', 'local') == 'local')
+                        <li class="nav-item">
+                            <a href="{{route('store.index')}}"
+                               class="nav-link @if(Request::routeIs('store.*') || Request::routeIs('checkout')) active @endif">
+                                <i class="nav-icon fa fa-coins"></i>
+                                <p>Store</p>
+                            </a>
+                        </li>
+                    @endif
 
                     @if(Auth::user()->role == 'admin')
                         <li class="nav-header">Admin</li>
@@ -189,6 +199,14 @@
                             </a>
                         </li>
 
+                        <li class="nav-item">
+                            <a href="{{route('admin.vouchers.index')}}"
+                               class="nav-link @if(Request::routeIs('admin.vouchers.*')) active @endif">
+                                <i class="nav-icon fas fa-money-check-alt"></i>
+                                <p>Vouchers</p>
+                            </a>
+                        </li>
+
                         <li class="nav-header">Pterodactyl</li>
 
                         <li class="nav-item">
@@ -227,6 +245,7 @@
                             </a>
                         </li>
 
+
                         <li class="nav-header">Dashboard</li>
 
                         <li class="nav-item">
@@ -237,6 +256,16 @@
                             </a>
                         </li>
 
+                        <li class="nav-item">
+                            <a href="{{route('admin.usefullinks.index')}}"
+                               class="nav-link @if(Request::routeIs('admin.usefullinks.*')) active @endif">
+                                <i class="nav-icon fas fa-link"></i>
+                                <p>Useful Links</p>
+                            </a>
+                        </li>
+
+                        <li class="nav-header">Settings</li>
+
                         <li class="nav-item">
                             <a href="{{route('admin.configurations.index')}}"
                                class="nav-link @if(Request::routeIs('admin.configurations.*')) active @endif">
@@ -253,15 +282,6 @@
                             </a>
                         </li>
 
-                        <li class="nav-item">
-                            <a href="{{route('admin.usefullinks.index')}}"
-                               class="nav-link @if(Request::routeIs('admin.usefullinks.*')) active @endif">
-                                <i class="nav-icon fas fa-link"></i>
-                                <p>Useful Links</p>
-                            </a>
-                        </li>
-
-
                     @endif
 
                 </ul>
@@ -276,7 +296,7 @@
     <div class="content-wrapper">
 
         @if(!Auth::user()->hasVerifiedEmail())
-            @if(Auth::user()->created_at->diffInHours(now(), false) > 2)
+            @if(Auth::user()->created_at->diffInHours(now(), false) > 1)
                 <div class="alert alert-warning p-2 m-2">
                     <h5><i class="icon fas fa-exclamation-circle"></i> Warning!</h5>
                     You have not yet verified your email address <a class="text-primary"
@@ -289,6 +309,8 @@
         @endif
 
         @yield('content')
+
+        @include('models.redeem_voucher_modal')
     </div>
     <!-- /.content-wrapper -->
     <footer class="main-footer">
@@ -316,9 +338,21 @@
 <!-- Summernote -->
 <script src="{{asset('plugins/summernote/summernote-bs4.min.js')}}"></script>
 
+<!-- Moment.js -->
+<script src="{{asset('plugins/moment/moment.min.js')}}"></script>
+
+<!-- Datetimepicker -->
+<script src="{{asset('plugins/tempusdominus-bootstrap-4/js/tempusdominus-bootstrap-4.min.js')}}"></script>
+
 <script>
     $(document).ready(function () {
         $('[data-toggle="popover"]').popover();
+
+        $.ajaxSetup({
+            headers: {
+                'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
+            }
+        });
     });
 </script>
 <script>

+ 107 - 0
resources/views/models/redeem_voucher_modal.blade.php

@@ -0,0 +1,107 @@
+<!-- The Modal -->
+<div class="modal fade" id="redeemVoucherModal">
+    <div class="modal-dialog">
+        <div class="modal-content">
+
+            <!-- Modal Header -->
+            <div class="modal-header">
+                <h4 class="modal-title">Redeem voucher code</h4>
+                <button type="button" class="close" data-dismiss="modal">&times;</button>
+            </div>
+
+            <!-- Modal body -->
+            <div class="modal-body">
+                <form id="redeemVoucherForm" onsubmit="return false" method="post" action="{{route('voucher.redeem')}}">
+                    <div class="form-group">
+                        <label for="redeemVoucherCode">Code</label>
+                        <div class="input-group">
+                            <div class="input-group-prepend">
+                                <div class="input-group-text">
+                                    <i class="fas fa-money-check-alt"></i>
+                                </div>
+                            </div>
+                            <input id="redeemVoucherCode" name="code" placeholder="SUMMER" type="text"
+                                   class="form-control">
+                        </div>
+                        <span id="redeemVoucherCodeError" class="text-danger"></span>
+                        <span id="redeemVoucherCodeSuccess" class="text-success"></span>
+                    </div>
+                </form>
+            </div>
+
+            <!-- Modal footer -->
+            <div class="modal-footer">
+                <button type="button" class="btn btn-danger" data-dismiss="modal">Close</button>
+                <button name="submit" id="redeemVoucherSubmit" onclick="redeemVoucherCode()" type="button"
+                        class="btn btn-primary">Redeem
+                </button>
+            </div>
+
+        </div>
+    </div>
+</div>
+
+
+<script>
+    function redeemVoucherCode() {
+        let form = document.getElementById('redeemVoucherForm')
+        let button = document.getElementById('redeemVoucherSubmit')
+        let input = document.getElementById('redeemVoucherCode')
+
+        console.log(form.method, form.action)
+        button.disabled = true
+
+        $.ajax({
+            method: form.method,
+            url: form.action,
+            dataType: 'json',
+            data: {
+                code: input.value
+            },
+            success: function (response) {
+                resetForm()
+                redeemVoucherSetSuccess(response)
+                setTimeout(() => {
+                    $('#redeemVoucherModal').modal('toggle');
+                } , 1500)
+            },
+            error: function (jqXHR, textStatus, errorThrown) {
+                resetForm()
+                redeemVoucherSetError(jqXHR)
+                console.error(jqXHR.responseJSON)
+            },
+
+        })
+    }
+
+    function resetForm() {
+        let button = document.getElementById('redeemVoucherSubmit')
+        let input = document.getElementById('redeemVoucherCode')
+        let successLabel = document.getElementById('redeemVoucherCodeSuccess')
+        let errorLabel = document.getElementById('redeemVoucherCodeError')
+
+        input.classList.remove('is-invalid')
+        input.classList.remove('is-valid')
+        successLabel.innerHTML = ''
+        errorLabel.innerHTML = ''
+        button.disabled = false
+    }
+
+    function redeemVoucherSetError(error) {
+        let input = document.getElementById('redeemVoucherCode')
+        let errorLabel = document.getElementById('redeemVoucherCodeError')
+
+        input.classList.add("is-invalid")
+
+        errorLabel.innerHTML = error.status === 422 ? error.responseJSON.errors.code[0] : error.responseJSON.message
+    }
+
+    function redeemVoucherSetSuccess(response) {
+        let input = document.getElementById('redeemVoucherCode')
+        let successLabel = document.getElementById('redeemVoucherCodeSuccess')
+
+        successLabel.innerHTML = response.success
+        input.classList.remove('is-invalid')
+        input.classList.add('is-valid')
+    }
+</script>

+ 35 - 2
resources/views/profile/index.blade.php

@@ -25,7 +25,33 @@
         <div class="container-fluid">
 
             <div class="row">
-                <div class="col-lg-4">
+                <div class="col-lg-12 px-0">
+                    @if(!Auth::user()->hasVerifiedEmail() && strtolower($force_email_verification) == 'true')
+                        <div class="alert alert-warning p-2 m-2">
+                            <h5><i class="icon fas fa-exclamation-circle"></i>Required Email verification!</h5>
+                            You have not yet verified your email address
+                            <a class="text-primary" href="{{route('verification.send')}}">Click here to resend
+                                verification email</a> <br>
+                            Please contact support If you didn't receive your verification email.
+                        </div>
+                    @endif
+
+                    @if(is_null(Auth::user()->discordUser) && strtolower($force_discord_verification) == 'true')
+                        @if(!empty(env('DISCORD_CLIENT_ID')) && !empty(env('DISCORD_CLIENT_SECRET')))
+                            <div class="alert alert-warning p-2 m-2">
+                                <h5><i class="icon fas fa-exclamation-circle"></i>Required Discord verification!</h5>
+                                You have not yet verified your discord account
+                                <a class="text-primary" href="{{route('auth.redirect')}}">Login with discord</a> <br>
+                                Please contact support If you face any issues.
+                            </div>
+                        @else
+                            <div class="alert alert-danger p-2 m-2">
+                                <h5><i class="icon fas fa-exclamation-circle"></i>Required Discord verification!</h5>
+                                Due to system settings you are required to verify your discord account! <br>
+                                It looks like this hasn't been set-up correctly! Please contact support.
+                            </div>
+                        @endif
+                    @endif
 
                 </div>
             </div>
@@ -50,7 +76,14 @@
                                 <div class="col d-flex flex-column flex-sm-row justify-content-between mb-3">
                                     <div class="text-center text-sm-left mb-2 mb-sm-0"><h4
                                             class="pt-sm-2 pb-1 mb-0 text-nowrap">{{$user->name}}</h4>
-                                        <p class="mb-0">{{$user->email}}</p>
+                                        <p class="mb-0">{{$user->email}}
+                                            @if($user->hasVerifiedEmail())
+                                                <i data-toggle="popover" data-trigger="hover" data-content="Verified" class="text-success fas fa-check-circle"></i>
+                                            @else
+                                                <i data-toggle="popover" data-trigger="hover" data-content="Not verified" class="text-danger fas fa-exclamation-circle"></i>
+                                            @endif
+
+                                        </p>
                                         <div class="mt-1">
                                             <span class="badge badge-primary"><i class="fa fa-coins mr-2"></i>{{$user->Credits()}}</span>
                                         </div>

+ 6 - 0
resources/views/store/index.blade.php

@@ -24,6 +24,12 @@
     <section class="content">
         <div class="container-fluid">
 
+            <div class="text-right mb-3">
+                <button type="button" data-toggle="modal" data-target="#redeemVoucherModal" class="btn btn-primary">
+                    <i class="fas fa-money-check-alt mr-2"></i>Redeem code
+                </button>
+            </div>
+
             @if($isPaypalSetup && $products->count() > 0)
 
                 <div class="card">

+ 8 - 1
routes/web.php

@@ -12,15 +12,16 @@ use App\Http\Controllers\Admin\ServerController as AdminServerController;
 use App\Http\Controllers\Admin\SettingsController;
 use App\Http\Controllers\Admin\UsefulLinkController;
 use App\Http\Controllers\Admin\UserController;
+use App\Http\Controllers\Admin\VoucherController;
 use App\Http\Controllers\Auth\SocialiteController;
 use App\Http\Controllers\HomeController;
 use App\Http\Controllers\NotificationController;
 use App\Http\Controllers\ProfileController;
 use App\Http\Controllers\ServerController;
 use App\Http\Controllers\StoreController;
+use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Route;
-use Illuminate\Http\Request;
 
 /*
 |--------------------------------------------------------------------------
@@ -65,6 +66,9 @@ Route::middleware('auth')->group(function () {
     Route::get('/auth/redirect', [SocialiteController::class, 'redirect'])->name('auth.redirect');
     Route::get('/auth/callback', [SocialiteController::class, 'callback'])->name('auth.callback');
 
+    #voucher redeem
+    Route::post('/voucher/redeem', [VoucherController::class, 'redeem'])->middleware('throttle:5,1')->name('voucher.redeem');
+
     #admin
     Route::prefix('admin')->name('admin.')->middleware('admin')->group(function () {
 
@@ -110,6 +114,9 @@ Route::middleware('auth')->group(function () {
         Route::get('usefullinks/datatable', [UsefulLinkController::class, 'datatable'])->name('usefullinks.datatable');
         Route::resource('usefullinks', UsefulLinkController::class);
 
+        Route::get('vouchers/datatable', [VoucherController::class, 'datatable'])->name('vouchers.datatable');
+        Route::resource('vouchers', VoucherController::class);
+
         Route::get('api/datatable', [ApplicationApiController::class, 'datatable'])->name('api.datatable');
         Route::resource('api', ApplicationApiController::class)->parameters([
             'api' => 'applicationApi',

+ 318 - 0
tests/Feature/TestVouchersController.php

@@ -0,0 +1,318 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\User;
+use App\Models\Voucher;
+use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Illuminate\Support\Str;
+use Tests\TestCase;
+
+/**
+ * Class TestUsefulLinksController
+ * @package Tests\Feature
+ */
+class TestVouchersController extends TestCase
+{
+    use DatabaseTransactions;
+
+    /**
+     * @dataProvider accessibleRoutesDataProvider
+     * @param string $method
+     * @param string $route
+     * @param int $expectedStatus
+     */
+    function test_accessible_routes(string $method, string $route, int $expectedStatus)
+    {
+        Voucher::factory()->create([
+            'id' => 1
+        ]);
+
+        $response = $this->actingAs(User::factory()->create([
+            'role'           => 'admin',
+            'pterodactyl_id' => '1'
+        ]))->{$method}($route);
+
+        $response->assertStatus($expectedStatus);
+    }
+
+    /**
+     * @dataProvider VoucherDataProvider
+     * @param array $dataSet
+     * @param int $expectedCount
+     * @param bool $assertValidationErrors
+     */
+    function test_creating_vouchers(array $dataSet, int $expectedCount, bool $assertValidationErrors)
+    {
+        $response = $this->actingAs($this->getTestUser())->post(route('admin.vouchers.store'), $dataSet);
+
+        if ($assertValidationErrors) $response->assertSessionHasErrors();
+        else $response->assertSessionHasNoErrors();
+
+        $response->assertRedirect();
+        $this->assertDatabaseCount('vouchers', $expectedCount);
+    }
+
+    /**
+     * @return User
+     */
+    private function getTestUser(): User
+    {
+        return User::factory()->create([
+            'role'           => 'admin',
+            'pterodactyl_id' => '1'
+        ]);
+    }
+
+    /**
+     * @dataProvider VoucherDataProvider
+     * @param array $dataSet
+     * @param int $expectedCount
+     * @param bool $assertValidationErrors
+     */
+    function test_updating_voucher(array $dataSet, int $expectedCount, bool $assertValidationErrors)
+    {
+        $voucher = Voucher::factory()->create([
+            'id' => 1
+        ]);
+
+        $response = $this->actingAs($this->getTestUser())->patch(route('admin.vouchers.update', $voucher->id), $dataSet);
+
+        if ($assertValidationErrors) $response->assertSessionHasErrors();
+        else $response->assertSessionHasNoErrors();
+
+        $response->assertRedirect();
+        $this->assertDatabaseCount('vouchers', 1);
+    }
+
+    /**
+     *
+     */
+    function test_deleting_vouchers()
+    {
+        $voucher = Voucher::factory()->create([
+            'id' => 1
+        ]);
+
+        $response = $this->actingAs($this->getTestUser())->delete(route('admin.vouchers.update', $voucher->id));
+
+        $response->assertRedirect();
+        $this->assertDatabaseCount('vouchers', 0);
+    }
+
+    /**
+     * @return array
+     */
+    function VoucherDataProvider(): array
+    {
+        return [
+            'Valid dataset 1'                => [
+                'dataSet'                => [
+                    "memo"       => "TESTING",
+                    "code"       => Str::random(20),
+                    "credits"    => 500,
+                    "uses"       => 500,
+                    "expires_at" => now()->addDay()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 1,
+                'assertValidationErrors' => false
+            ],
+            'Valid dataset 2'                => [
+                'dataSet'                => [
+                    "code"    => Str::random(36),
+                    "credits" => 500,
+                    "uses"    => 500,
+                ],
+                'expectedCount'          => 1,
+                'assertValidationErrors' => false
+            ],
+            'Valid dataset 3'                => [
+                'dataSet'                => [
+                    "memo"       => "TESTING",
+                    "code"       => Str::random(4),
+                    "credits"    => 1000000,
+                    "uses"       => 1,
+                    "expires_at" => now()->addYears(6)->format('d-m-Y'),
+                ],
+                'expectedCount'          => 1,
+                'assertValidationErrors' => false
+            ],
+            'Invalid dataset (memo to long)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(20),
+                    "credits"    => 500,
+                    "uses"       => 500,
+                    "expires_at" => now()->addDay()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (code to short)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "credits"    => 500,
+                    "uses"       => 500,
+                    "expires_at" => now()->addDay()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (code missing)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "credits"    => 500,
+                    "uses"       => 500,
+                    "expires_at" => now()->addDay()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (code to long)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(60),
+                    "credits"    => 500,
+                    "uses"       => 500,
+                    "expires_at" => now()->addDay()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (credits missing)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "uses"       => 500,
+                    "expires_at" => now()->addDay()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (0 credits)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "credits"    => 0,
+                    "uses"       => 500,
+                    "expires_at" => now()->addDay()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (to many credits)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "credits"    => 99999999999,
+                    "uses"       => 500,
+                    "expires_at" => now()->addDay()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (uses missing)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "credits"    => 99999999999,
+                    "expires_at" => now()->addDay()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (0 uses)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "credits"    => 99999999999,
+                    "uses"       => 0,
+                    "expires_at" => now()->addDay()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (expires_at today)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "credits"    => 99999999999,
+                    "uses"       => 500,
+                    "expires_at" => now()->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (expires_at earlier)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "credits"    => 99999999999,
+                    "uses"       => 500,
+                    "expires_at" => now()->subDays(5)->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (expires_at to far)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "credits"    => 99999999999,
+                    "uses"       => 500,
+                    "expires_at" => now()->addYears(100)->format('d-m-Y'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (expires_at invalid format 1)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "credits"    => 99999999999,
+                    "uses"       => 500,
+                    "expires_at" => now()->addYears(100)->format('Y-m-d'),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+            'Invalid dataset (expires_at invalid value)' => [
+                'dataSet'                => [
+                    "memo"       => Str::random(250),
+                    "code"       => Str::random(1),
+                    "credits"    => 99999999999,
+                    "uses"       => 500,
+                    "expires_at" => Str::random(20),
+                ],
+                'expectedCount'          => 0,
+                'assertValidationErrors' => true
+            ],
+        ];
+
+    }
+
+    /**
+     * @return array[]
+     */
+    public function accessibleRoutesDataProvider(): array
+    {
+        return [
+            'index page'  => [
+                'method'         => 'get',
+                'route'          => '/admin/vouchers',
+                'expectedStatus' => 200
+            ],
+            'Create page' => [
+                'method'         => 'get',
+                'route'          => '/admin/vouchers/create',
+                'expectedStatus' => 200
+            ],
+            'Edit page'   => [
+                'method'         => 'get',
+                'route'          => '/admin/vouchers/1/edit',
+                'expectedStatus' => 200
+            ],
+        ];
+    }
+}