Point System Guide - luckydeva03/barbershop_app GitHub Wiki
Panduan lengkap sistem poin loyalty customer dalam barbershop management system.
Sistem poin adalah fitur loyalty program untuk meningkatkan engagement customer dengan memberikan reward berupa poin yang dapat diperoleh melalui berbagai aktivitas seperti redeem code, menulis review, dan bonus khusus.
- Point Earning: Berbagai cara mendapatkan poin
- Redeem Code System: Kode promo dari barbershop
- Transaction History: Riwayat lengkap aktivitas poin
- Expiration Management: Otomatis expired poin lama
- Rate Limiting: Pembatasan penggunaan untuk mencegah abuse
<?php
// database/migrations/2024_12_17_111332_create_history_points_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('history_points', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('store_id')->nullable()->constrained()->onDelete('set null');
$table->integer('points'); // positive for earned, negative for spent/expired
$table->enum('type', ['earned', 'spent', 'expired', 'bonus', 'refund']);
$table->string('description');
$table->string('reference_code')->nullable(); // redeem code or transaction ref
$table->json('metadata')->nullable(); // additional data
$table->integer('running_balance')->default(0); // balance after this transaction
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'created_at']);
$table->index(['type', 'created_at']);
$table->index('expires_at');
});
}
public function down()
{
Schema::dropIfExists('history_points');
}
};
<?php
// database/migrations/2024_12_17_151944_create_reedem_codes_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('reedem_codes', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->integer('points');
$table->text('description')->nullable();
$table->integer('max_uses')->default(1);
$table->integer('used_count')->default(0);
$table->integer('max_uses_per_user')->default(1);
$table->datetime('starts_at')->nullable();
$table->datetime('expires_at')->nullable();
$table->boolean('is_active')->default(true);
$table->json('metadata')->nullable(); // store restrictions, user groups, etc.
$table->timestamps();
$table->index(['code', 'is_active']);
$table->index(['expires_at', 'is_active']);
});
}
public function down()
{
Schema::dropIfExists('reedem_codes');
}
};
<?php
// database/migrations/xxxx_create_user_reedem_code_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('user_reedem_code', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('reedem_code_id')->constrained()->onDelete('cascade');
$table->integer('points_earned');
$table->timestamp('redeemed_at');
$table->unique(['user_id', 'reedem_code_id']);
$table->index('redeemed_at');
});
}
public function down()
{
Schema::dropIfExists('user_reedem_code');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class HistoryPoint extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'store_id',
'points',
'type',
'description',
'reference_code',
'metadata',
'running_balance',
'expires_at',
];
protected $casts = [
'metadata' => 'array',
'expires_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
// Relationships
public function user()
{
return $this->belongsTo(User::class);
}
public function store()
{
return $this->belongsTo(Store::class);
}
// Scopes
public function scopeEarned($query)
{
return $query->where('type', 'earned');
}
public function scopeSpent($query)
{
return $query->where('type', 'spent');
}
public function scopeExpired($query)
{
return $query->where('type', 'expired');
}
public function scopeBonus($query)
{
return $query->where('type', 'bonus');
}
public function scopeForUser($query, $userId)
{
return $query->where('user_id', $userId);
}
public function scopeWithinDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('created_at', [$startDate, $endDate]);
}
// Accessors
public function getFormattedPointsAttribute()
{
return ($this->points > 0 ? '+' : '') . number_format($this->points);
}
public function getPointsClassAttribute()
{
return match($this->type) {
'earned', 'bonus', 'refund' => 'text-success',
'spent' => 'text-primary',
'expired' => 'text-danger',
default => 'text-muted'
};
}
public function getTypeIconAttribute()
{
return match($this->type) {
'earned' => 'fas fa-plus-circle',
'spent' => 'fas fa-minus-circle',
'expired' => 'fas fa-clock',
'bonus' => 'fas fa-gift',
'refund' => 'fas fa-undo',
default => 'fas fa-circle'
};
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ReedemCode extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'code',
'points',
'description',
'max_uses',
'used_count',
'max_uses_per_user',
'starts_at',
'expires_at',
'is_active',
'metadata',
];
protected $casts = [
'starts_at' => 'datetime',
'expires_at' => 'datetime',
'is_active' => 'boolean',
'metadata' => 'array',
];
// Relationships
public function users()
{
return $this->belongsToMany(User::class, 'user_reedem_code')
->withPivot('points_earned', 'redeemed_at')
->withTimestamps();
}
public function historyPoints()
{
return $this->hasMany(HistoryPoint::class, 'reference_code', 'code');
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeAvailable($query)
{
return $query->active()
->where(function($q) {
$q->whereNull('starts_at')
->orWhere('starts_at', '<=', now());
})
->where(function($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->whereRaw('used_count < max_uses');
}
public function scopeExpired($query)
{
return $query->where('expires_at', '<', now());
}
public function scopeExhausted($query)
{
return $query->whereRaw('used_count >= max_uses');
}
// Accessors
public function getIsValidAttribute()
{
return $this->is_active &&
$this->used_count < $this->max_uses &&
($this->starts_at === null || $this->starts_at <= now()) &&
($this->expires_at === null || $this->expires_at > now());
}
public function getUsagePercentageAttribute()
{
return $this->max_uses > 0 ? ($this->used_count / $this->max_uses) * 100 : 0;
}
public function getStatusAttribute()
{
if (!$this->is_active) return 'inactive';
if ($this->expires_at && $this->expires_at < now()) return 'expired';
if ($this->used_count >= $this->max_uses) return 'exhausted';
if ($this->starts_at && $this->starts_at > now()) return 'pending';
return 'active';
}
public function getStatusBadgeAttribute()
{
return match($this->status) {
'active' => '<span class="badge bg-success">Active</span>',
'pending' => '<span class="badge bg-warning">Pending</span>',
'expired' => '<span class="badge bg-danger">Expired</span>',
'exhausted' => '<span class="badge bg-secondary">Exhausted</span>',
'inactive' => '<span class="badge bg-dark">Inactive</span>',
default => '<span class="badge bg-light">Unknown</span>'
};
}
// Methods
public function canBeRedeemedBy(User $user)
{
if (!$this->is_valid) return false;
$userUsage = $this->users()
->where('user_id', $user->id)
->count();
return $userUsage < $this->max_uses_per_user;
}
public function redeem(User $user, $store = null)
{
if (!$this->canBeRedeemedBy($user)) {
throw new \Exception('Code cannot be redeemed by this user');
}
\DB::transaction(function() use ($user, $store) {
// Record pivot
$this->users()->attach($user->id, [
'points_earned' => $this->points,
'redeemed_at' => now(),
]);
// Create history point
$historyPoint = HistoryPoint::create([
'user_id' => $user->id,
'store_id' => $store?->id,
'points' => $this->points,
'type' => 'earned',
'description' => "Redeem code: {$this->code} - {$this->description}",
'reference_code' => $this->code,
'metadata' => [
'code_id' => $this->id,
'code_description' => $this->description,
'store_name' => $store?->name,
],
'running_balance' => $user->fresh()->total_points,
'expires_at' => now()->addYear(), // Points expire in 1 year
]);
// Update code usage
$this->increment('used_count');
});
return true;
}
// Static methods
public static function generateCode($length = 8)
{
do {
$code = strtoupper(substr(str_shuffle('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), 0, $length));
} while (static::where('code', $code)->exists());
return $code;
}
}
<?php
// Add to existing User model (app/Models/User.php)
// Add relationships
public function historyPoints()
{
return $this->hasMany(HistoryPoint::class);
}
public function reedemCodes()
{
return $this->belongsToMany(ReedemCode::class, 'user_reedem_code')
->withPivot('points_earned', 'redeemed_at')
->withTimestamps();
}
// Add point-related methods
public function getTotalPointsAttribute()
{
return $this->historyPoints()
->whereIn('type', ['earned', 'bonus', 'refund'])
->where(function($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->sum('points') -
$this->historyPoints()
->where('type', 'spent')
->sum('points');
}
public function getActivePointsAttribute()
{
return $this->historyPoints()
->whereIn('type', ['earned', 'bonus', 'refund'])
->where(function($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->sum('points');
}
public function getExpiredPointsAttribute()
{
return $this->historyPoints()
->whereIn('type', ['earned', 'bonus', 'refund'])
->where('expires_at', '<', now())
->sum('points');
}
public function addPoints($points, $type = 'earned', $description = '', $store = null, $referenceCode = null, $expiresAt = null)
{
return HistoryPoint::create([
'user_id' => $this->id,
'store_id' => $store?->id,
'points' => $points,
'type' => $type,
'description' => $description,
'reference_code' => $referenceCode,
'running_balance' => $this->fresh()->total_points,
'expires_at' => $expiresAt ?: now()->addYear(),
]);
}
public function spendPoints($points, $description = '', $store = null, $referenceCode = null)
{
if ($points > $this->total_points) {
throw new \Exception('Insufficient points');
}
return HistoryPoint::create([
'user_id' => $this->id,
'store_id' => $store?->id,
'points' => -$points,
'type' => 'spent',
'description' => $description,
'reference_code' => $referenceCode,
'running_balance' => $this->fresh()->total_points,
]);
}
public function canRedeemCode($code)
{
$reedemCode = ReedemCode::where('code', $code)->first();
return $reedemCode && $reedemCode->canBeRedeemedBy($this);
}
<?php
namespace App\Http\Controllers;
use App\Models\ReedemCode;
use App\Models\HistoryPoint;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class PointController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function dashboard()
{
$user = auth()->user();
$pointsData = [
'total_points' => $user->total_points,
'active_points' => $user->active_points,
'expired_points' => $user->expired_points,
'lifetime_earned' => $user->historyPoints()
->whereIn('type', ['earned', 'bonus'])
->sum('points'),
'lifetime_spent' => $user->historyPoints()
->where('type', 'spent')
->sum('points'),
];
$recentTransactions = $user->historyPoints()
->with('store')
->latest()
->limit(10)
->get();
$monthlyStats = $this->getMonthlyStats($user);
return view('points.dashboard', compact('pointsData', 'recentTransactions', 'monthlyStats'));
}
public function history(Request $request)
{
$user = auth()->user();
$perPage = $request->get('per_page', 20);
$query = $user->historyPoints()->with('store');
// Filters
if ($request->filled('type')) {
$query->where('type', $request->type);
}
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->date_from);
}
if ($request->filled('date_to')) {
$query->whereDate('created_at', '<=', $request->date_to);
}
if ($request->filled('search')) {
$query->where('description', 'like', '%' . $request->search . '%');
}
$transactions = $query->latest()->paginate($perPage);
return view('points.history', compact('transactions'));
}
public function redeem(Request $request)
{
$request->validate([
'code' => 'required|string|max:20',
]);
$user = auth()->user();
$code = strtoupper(trim($request->code));
// Rate limiting
$rateLimitKey = 'redeem_attempts:' . $user->id;
if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
return back()->withErrors([
'code' => "Too many attempts. Please try again in {$seconds} seconds."
]);
}
RateLimiter::hit($rateLimitKey, 300); // 5 minutes lockout
// Daily redeem limit
$dailyRedeems = $user->historyPoints()
->where('type', 'earned')
->whereDate('created_at', today())
->whereNotNull('reference_code')
->count();
if ($dailyRedeems >= 5) {
return back()->withErrors([
'code' => 'Daily redeem limit reached (5 codes per day).'
]);
}
// Find code
$reedemCode = ReedemCode::where('code', $code)->first();
if (!$reedemCode) {
return back()->withErrors([
'code' => 'Invalid code. Please check and try again.'
]);
}
// Validate code
if (!$reedemCode->is_valid) {
$message = match($reedemCode->status) {
'expired' => 'This code has expired.',
'exhausted' => 'This code has reached its usage limit.',
'inactive' => 'This code is not active.',
'pending' => 'This code is not yet available.',
default => 'This code cannot be used.'
};
return back()->withErrors(['code' => $message]);
}
// Check user eligibility
if (!$reedemCode->canBeRedeemedBy($user)) {
return back()->withErrors([
'code' => 'You have already used this code the maximum number of times.'
]);
}
try {
$reedemCode->redeem($user);
RateLimiter::clear($rateLimitKey); // Clear rate limit on success
return back()->with('success',
"Congratulations! You earned {$reedemCode->points} points from code '{$code}'."
);
} catch (\Exception $e) {
return back()->withErrors([
'code' => 'Failed to redeem code. Please try again.'
]);
}
}
public function stats()
{
$user = auth()->user();
$stats = [
'total_points' => $user->total_points,
'points_this_month' => $user->historyPoints()
->whereIn('type', ['earned', 'bonus'])
->whereMonth('created_at', now()->month)
->sum('points'),
'codes_redeemed' => $user->reedemCodes()->count(),
'reviews_written' => $user->reviews()->count(),
'points_from_reviews' => $user->historyPoints()
->where('type', 'earned')
->where('description', 'like', '%review%')
->sum('points'),
'expiring_soon' => $user->historyPoints()
->whereIn('type', ['earned', 'bonus'])
->whereBetween('expires_at', [now(), now()->addDays(30)])
->sum('points'),
];
$chartData = $this->getChartData($user);
return view('points.stats', compact('stats', 'chartData'));
}
private function getMonthlyStats($user)
{
$months = [];
for ($i = 5; $i >= 0; $i--) {
$date = now()->subMonths($i);
$earned = $user->historyPoints()
->whereIn('type', ['earned', 'bonus'])
->whereMonth('created_at', $date->month)
->whereYear('created_at', $date->year)
->sum('points');
$spent = $user->historyPoints()
->where('type', 'spent')
->whereMonth('created_at', $date->month)
->whereYear('created_at', $date->year)
->sum('points');
$months[] = [
'month' => $date->format('M Y'),
'earned' => $earned,
'spent' => abs($spent),
'net' => $earned - abs($spent),
];
}
return $months;
}
private function getChartData($user)
{
$dailyPoints = $user->historyPoints()
->selectRaw('DATE(created_at) as date, SUM(points) as total')
->where('created_at', '>=', now()->subDays(30))
->groupBy('date')
->orderBy('date')
->get()
->pluck('total', 'date')
->toArray();
$typeDistribution = $user->historyPoints()
->selectRaw('type, SUM(ABS(points)) as total')
->groupBy('type')
->get()
->pluck('total', 'type')
->toArray();
return [
'daily_points' => $dailyPoints,
'type_distribution' => $typeDistribution,
];
}
}
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ReedemCode;
use Illuminate\Http\Request;
class ReedemCodeController extends Controller
{
public function index()
{
$codes = ReedemCode::withCount('users')
->latest()
->paginate(20);
return view('admin.codes.index', compact('codes'));
}
public function create()
{
return view('admin.codes.create');
}
public function store(Request $request)
{
$request->validate([
'code' => 'nullable|string|max:20|unique:reedem_codes',
'points' => 'required|integer|min:1|max:10000',
'description' => 'nullable|string|max:500',
'max_uses' => 'required|integer|min:1',
'max_uses_per_user' => 'required|integer|min:1',
'starts_at' => 'nullable|date|after_or_equal:today',
'expires_at' => 'nullable|date|after:starts_at',
]);
$data = $request->all();
$data['code'] = $data['code'] ?: ReedemCode::generateCode();
$data['code'] = strtoupper($data['code']);
ReedemCode::create($data);
return redirect()->route('admin.codes.index')
->with('success', 'Redeem code created successfully!');
}
public function show(ReedemCode $code)
{
$code->loadCount('users');
$usage = $code->users()
->withPivot('points_earned', 'redeemed_at')
->latest('pivot_redeemed_at')
->paginate(20);
return view('admin.codes.show', compact('code', 'usage'));
}
public function edit(ReedemCode $code)
{
return view('admin.codes.edit', compact('code'));
}
public function update(Request $request, ReedemCode $code)
{
$request->validate([
'description' => 'nullable|string|max:500',
'max_uses' => 'required|integer|min:' . $code->used_count,
'max_uses_per_user' => 'required|integer|min:1',
'starts_at' => 'nullable|date',
'expires_at' => 'nullable|date|after:starts_at',
'is_active' => 'boolean',
]);
$code->update($request->all());
return redirect()->route('admin.codes.index')
->with('success', 'Redeem code updated successfully!');
}
public function destroy(ReedemCode $code)
{
if ($code->used_count > 0) {
return back()->withErrors([
'error' => 'Cannot delete code that has been used.'
]);
}
$code->delete();
return redirect()->route('admin.codes.index')
->with('success', 'Redeem code deleted successfully!');
}
public function bulk(Request $request)
{
$request->validate([
'action' => 'required|in:activate,deactivate,delete',
'codes' => 'required|array',
'codes.*' => 'exists:reedem_codes,id',
]);
$codes = ReedemCode::whereIn('id', $request->codes);
switch ($request->action) {
case 'activate':
$codes->update(['is_active' => true]);
$message = 'Selected codes activated successfully!';
break;
case 'deactivate':
$codes->update(['is_active' => false]);
$message = 'Selected codes deactivated successfully!';
break;
case 'delete':
$codes->where('used_count', 0)->delete();
$message = 'Unused codes deleted successfully!';
break;
}
return back()->with('success', $message);
}
}
@extends('layouts.app')
@section('title', 'My Points')
@section('content')
<div class="container">
<!-- Points Summary Cards -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card text-center border-primary">
<div class="card-body">
<div class="display-4 text-primary">{{ number_format($pointsData['total_points']) }}</div>
<h6 class="card-title text-muted">Total Points</h6>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center border-success">
<div class="card-body">
<div class="h2 text-success">{{ number_format($pointsData['active_points']) }}</div>
<h6 class="card-title text-muted">Active Points</h6>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center border-warning">
<div class="card-body">
<div class="h2 text-warning">{{ number_format($pointsData['lifetime_earned']) }}</div>
<h6 class="card-title text-muted">Lifetime Earned</h6>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center border-info">
<div class="card-body">
<div class="h2 text-info">{{ number_format($pointsData['lifetime_spent']) }}</div>
<h6 class="card-title text-muted">Lifetime Spent</h6>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Redeem Code Section -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-ticket-alt"></i>
Redeem Code
</h5>
</div>
<div class="card-body">
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show">
<i class="fas fa-check-circle"></i>
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
@if($errors->has('code'))
<div class="alert alert-danger alert-dismissible fade show">
<i class="fas fa-exclamation-circle"></i>
{{ $errors->first('code') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
<form method="POST" action="{{ route('points.redeem') }}">
@csrf
<div class="input-group mb-3">
<input type="text"
class="form-control form-control-lg"
name="code"
placeholder="Enter your code (e.g., WELCOME50)"
style="text-transform: uppercase"
maxlength="20"
required>
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-gift"></i>
Redeem
</button>
</div>
</form>
<small class="text-muted">
<i class="fas fa-info-circle"></i>
Codes are not case-sensitive. Maximum 5 codes per day.
</small>
</div>
</div>
</div>
<!-- Monthly Chart -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-line"></i>
Points Trend (Last 6 Months)
</h5>
</div>
<div class="card-body">
<canvas id="monthlyChart" height="200"></canvas>
</div>
</div>
</div>
</div>
<!-- Recent Transactions -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-history"></i>
Recent Transactions
</h5>
<a href="{{ route('points.history') }}" class="btn btn-outline-primary btn-sm">
View All History
</a>
</div>
<div class="card-body">
@if($recentTransactions->isEmpty())
<div class="text-center py-4">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<p class="text-muted">No transactions yet</p>
<small class="text-muted">Start redeeming codes to earn points!</small>
</div>
@else
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Description</th>
<th>Points</th>
<th>Balance</th>
</tr>
</thead>
<tbody>
@foreach($recentTransactions as $transaction)
<tr>
<td>{{ $transaction->created_at->format('M d, Y H:i') }}</td>
<td>
<i class="{{ $transaction->type_icon }} me-1"></i>
<span class="badge bg-{{ $transaction->type === 'earned' ? 'success' : ($transaction->type === 'spent' ? 'primary' : 'warning') }}">
{{ ucfirst($transaction->type) }}
</span>
</td>
<td>
{{ $transaction->description }}
@if($transaction->store)
<br><small class="text-muted">at {{ $transaction->store->name }}</small>
@endif
</td>
<td class="fw-bold {{ $transaction->points_class }}">
{{ $transaction->formatted_points }}
</td>
<td>{{ number_format($transaction->running_balance) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const ctx = document.getElementById('monthlyChart').getContext('2d');
const monthlyData = @json($monthlyStats);
new Chart(ctx, {
type: 'line',
data: {
labels: monthlyData.map(item => item.month),
datasets: [{
label: 'Points Earned',
data: monthlyData.map(item => item.earned),
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.1
}, {
label: 'Points Spent',
data: monthlyData.map(item => item.spent),
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
},
plugins: {
legend: {
position: 'top',
},
tooltip: {
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + context.parsed.y + ' points';
}
}
}
}
}
});
});
</script>
@endpush
@endsection
@extends('layouts.app')
@section('title', 'Point History')
@section('content')
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Point History</h1>
<div>
<a href="{{ route('points.dashboard') }}" class="btn btn-outline-primary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="{{ route('points.history') }}">
<div class="row g-3">
<div class="col-md-3">
<label for="type" class="form-label">Type</label>
<select name="type" id="type" class="form-select">
<option value="">All Types</option>
<option value="earned" {{ request('type') === 'earned' ? 'selected' : '' }}>Earned</option>
<option value="spent" {{ request('type') === 'spent' ? 'selected' : '' }}>Spent</option>
<option value="bonus" {{ request('type') === 'bonus' ? 'selected' : '' }}>Bonus</option>
<option value="expired" {{ request('type') === 'expired' ? 'selected' : '' }}>Expired</option>
</select>
</div>
<div class="col-md-3">
<label for="date_from" class="form-label">From Date</label>
<input type="date" name="date_from" id="date_from" class="form-control" value="{{ request('date_from') }}">
</div>
<div class="col-md-3">
<label for="date_to" class="form-label">To Date</label>
<input type="date" name="date_to" id="date_to" class="form-control" value="{{ request('date_to') }}">
</div>
<div class="col-md-3">
<label for="search" class="form-label">Search</label>
<input type="text" name="search" id="search" class="form-control" placeholder="Search description..." value="{{ request('search') }}">
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">
<i class="fas fa-filter"></i> Filter
</button>
<a href="{{ route('points.history') }}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i> Clear
</a>
</div>
</form>
</div>
</div>
<!-- Results -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Transaction History ({{ $transactions->total() }} records)</h5>
</div>
<div class="card-body">
@if($transactions->isEmpty())
<div class="text-center py-5">
<i class="fas fa-search fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No transactions found</h5>
<p class="text-muted">Try adjusting your filters or date range</p>
</div>
@else
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Date & Time</th>
<th>Type</th>
<th>Description</th>
<th>Store</th>
<th>Points</th>
<th>Balance</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
@foreach($transactions as $transaction)
<tr>
<td>
<div>{{ $transaction->created_at->format('M d, Y') }}</div>
<small class="text-muted">{{ $transaction->created_at->format('H:i:s') }}</small>
</td>
<td>
<i class="{{ $transaction->type_icon }} me-1"></i>
<span class="badge bg-{{ $transaction->type === 'earned' ? 'success' : ($transaction->type === 'spent' ? 'primary' : ($transaction->type === 'bonus' ? 'warning' : 'danger')) }}">
{{ ucfirst($transaction->type) }}
</span>
</td>
<td>
{{ $transaction->description }}
@if($transaction->reference_code)
<br><small class="text-muted font-monospace">{{ $transaction->reference_code }}</small>
@endif
</td>
<td>
@if($transaction->store)
<span class="badge bg-light text-dark">{{ $transaction->store->name }}</span>
@else
<span class="text-muted">-</span>
@endif
</td>
<td class="fw-bold {{ $transaction->points_class }}">
{{ $transaction->formatted_points }}
</td>
<td class="fw-bold">{{ number_format($transaction->running_balance) }}</td>
<td>
@if($transaction->expires_at)
@if($transaction->expires_at < now())
<span class="badge bg-danger">Expired</span>
@elseif($transaction->expires_at < now()->addDays(30))
<span class="badge bg-warning">{{ $transaction->expires_at->diffForHumans() }}</span>
@else
<small class="text-muted">{{ $transaction->expires_at->format('M d, Y') }}</small>
@endif
@else
<span class="text-muted">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Showing {{ $transactions->firstItem() }} to {{ $transactions->lastItem() }} of {{ $transactions->total() }} results
</div>
{{ $transactions->links() }}
</div>
@endif
</div>
</div>
</div>
@endsection
<?php
namespace App\Console\Commands;
use App\Models\HistoryPoint;
use App\Models\User;
use Illuminate\Console\Command;
class ExpirePoints extends Command
{
protected $signature = 'points:expire {--dry-run : Show what would be expired without actually expiring}';
protected $description = 'Expire old points and create expiry records';
public function handle()
{
$dryRun = $this->option('dry-run');
$this->info('🕐 Checking for expired points...');
// Find points that should be expired
$expiredPoints = HistoryPoint::whereIn('type', ['earned', 'bonus', 'refund'])
->where('expires_at', '<', now())
->whereNotExists(function($query) {
$query->select('id')
->from('history_points as hp2')
->whereColumn('hp2.user_id', 'history_points.user_id')
->where('hp2.type', 'expired')
->whereColumn('hp2.reference_code', 'history_points.reference_code');
})
->get();
if ($expiredPoints->isEmpty()) {
$this->info('✅ No points to expire');
return 0;
}
$this->info("Found {$expiredPoints->count()} expired point records");
if ($dryRun) {
$this->table(
['User', 'Points', 'Expired Date', 'Description'],
$expiredPoints->map(function($point) {
return [
$point->user->name,
$point->points,
$point->expires_at->format('Y-m-d'),
$point->description,
];
})->toArray()
);
$this->info('🔍 Dry run mode - no points were expired');
return 0;
}
$bar = $this->output->createProgressBar($expiredPoints->count());
$bar->start();
$totalExpired = 0;
// Group by user for batch processing
$pointsByUser = $expiredPoints->groupBy('user_id');
foreach ($pointsByUser as $userId => $userPoints) {
$user = User::find($userId);
$pointsToExpire = $userPoints->sum('points');
// Create expiry record
HistoryPoint::create([
'user_id' => $userId,
'points' => -$pointsToExpire,
'type' => 'expired',
'description' => "Points expired ({$userPoints->count()} transactions)",
'running_balance' => $user->fresh()->total_points - $pointsToExpire,
]);
$totalExpired += $pointsToExpire;
$bar->advance($userPoints->count());
}
$bar->finish();
$this->line('');
$this->info("✅ Expired {$totalExpired} points for {$pointsByUser->count()} users");
return 0;
}
}
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
class GiveWelcomeBonus extends Command
{
protected $signature = 'points:welcome-bonus {--user-id= : Specific user ID}';
protected $description = 'Give welcome bonus to new users';
public function handle()
{
$userId = $this->option('user-id');
if ($userId) {
$users = User::where('id', $userId)->get();
} else {
// Find users who haven't received welcome bonus
$users = User::whereDoesntHave('historyPoints', function($query) {
$query->where('type', 'bonus')
->where('description', 'like', '%Welcome bonus%');
})->get();
}
if ($users->isEmpty()) {
$this->info('No users found for welcome bonus');
return 0;
}
$this->info("Giving welcome bonus to {$users->count()} users...");
$bar = $this->output->createProgressBar($users->count());
$bar->start();
$bonusPoints = 50; // Welcome bonus amount
foreach ($users as $user) {
$user->addPoints(
$bonusPoints,
'bonus',
'Welcome bonus - Thank you for joining!',
null,
'WELCOME_BONUS',
now()->addYear()
);
$bar->advance();
}
$bar->finish();
$this->line('');
$this->info("✅ Gave {$bonusPoints} welcome bonus points to {$users->count()} users");
return 0;
}
}
Add to app/Console/Kernel.php
:
protected function schedule(Schedule $schedule)
{
// Expire old points daily at midnight
$schedule->command('points:expire')->dailyAt('00:01');
// Give welcome bonus to new users (check hourly)
$schedule->command('points:welcome-bonus')->hourly();
// Birthday bonus (check daily)
$schedule->command('points:birthday-bonus')->dailyAt('09:00');
}
# Via seeder
php artisan db:seed --class=ReedemCodeSeeder
# Via command line
php artisan tinker
>>> ReedemCode::create([
'code' => 'NEWUSER50',
'points' => 50,
'description' => 'New user bonus',
'max_uses' => 1000,
'max_uses_per_user' => 1,
'expires_at' => now()->addMonths(3),
]);
// Welcome bonus
$user->addPoints(50, 'bonus', 'Welcome to our platform!');
// Review reward
$user->addPoints(10, 'earned', 'Review written for ' . $store->name, $store);
// Birthday bonus
$user->addPoints(100, 'bonus', 'Happy Birthday!', null, 'BIRTHDAY_' . date('Y'));
Next: WhatsApp Integration untuk setup dan konfigurasi WhatsApp booking.