feat: Implement member point redemption system with studio verification and enhanced reward management.
This commit is contained in:
parent
e07a2386cf
commit
4febd74674
33
app/Enums/RedemptionStatus.php
Normal file
33
app/Enums/RedemptionStatus.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\Enums\WithCommentEnum;
|
||||
use App\Traits\Enums\WithValueEnum;
|
||||
|
||||
enum RedemptionStatus: int
|
||||
{
|
||||
use WithCommentEnum, WithValueEnum;
|
||||
|
||||
case WAITING_PICKUP = 1;
|
||||
case PICKED_UP = 2;
|
||||
case EXPIRED = 3;
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WAITING_PICKUP => 'Menunggu Pengambilan',
|
||||
self::PICKED_UP => 'Sudah Diambil',
|
||||
self::EXPIRED => 'Kedaluwarsa',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::WAITING_PICKUP => 'yellow',
|
||||
self::PICKED_UP => 'emerald',
|
||||
self::EXPIRED => 'red',
|
||||
};
|
||||
}
|
||||
}
|
||||
39
app/Enums/RewardCategory.php
Normal file
39
app/Enums/RewardCategory.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Traits\Enums\WithCommentEnum;
|
||||
use App\Traits\Enums\WithValueEnum;
|
||||
|
||||
enum RewardCategory: int
|
||||
{
|
||||
use WithCommentEnum, WithValueEnum;
|
||||
|
||||
case EXCLUSIVE = 1;
|
||||
case REGULAR = 2;
|
||||
case LIMITED_EDITION = 3;
|
||||
case SEASONAL = 4;
|
||||
case SIGNATURE = 5;
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::EXCLUSIVE => 'Eksklusif',
|
||||
self::REGULAR => 'Regular',
|
||||
self::LIMITED_EDITION => 'Limited Edition',
|
||||
self::SEASONAL => 'Seasonal',
|
||||
self::SIGNATURE => 'Signature',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::EXCLUSIVE => 'amber',
|
||||
self::REGULAR => 'slate',
|
||||
self::LIMITED_EDITION => 'rose',
|
||||
self::SEASONAL => 'indigo',
|
||||
self::SIGNATURE => 'emerald',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Traits\Datatable\WithConfiguration;
|
||||
use App\Traits\Datatable\WithPrependColumn;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||
|
||||
@ -33,11 +34,44 @@ public function columns(): array
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Column::make('Kategori', 'category')
|
||||
->label(fn ($row, $column) => Blade::render('<flux:badge color="'.$row->category->color().'">'.$row->category->label().'</flux:badge>'))
|
||||
->html(),
|
||||
|
||||
Column::make('Visibilitas', 'is_show')
|
||||
->label(fn ($row, $column) => Blade::render('<flux:badge color="'.$row->is_show->color().'">'.$row->is_show->label().'</flux:badge>'))
|
||||
->html(),
|
||||
|
||||
Column::make('Tanggal')
|
||||
->label(function ($row) {
|
||||
$startDate = formatDateLocalized($row->start_date);
|
||||
$startAgo = formatRelativeTime($row->start_date);
|
||||
|
||||
$endDate = formatDateLocalized($row->end_date);
|
||||
$endAgo = formatRelativeTime($row->end_date);
|
||||
|
||||
return <<<HTML
|
||||
<div class="flex flex-col text-xs text-gray-700 dark:text-gray-300 gap-0.5">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Tgl Dimulai:</span>
|
||||
<span>{$startDate}</span>
|
||||
<span class="text-gray-500">({$startAgo})</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Tgl Berakhir:</span>
|
||||
<span>{$endDate}</span>
|
||||
<span class="text-gray-500">({$endAgo})</span>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
})
|
||||
->html(),
|
||||
|
||||
Column::make('Aksi')
|
||||
->label(function ($row) {
|
||||
$actions = '';
|
||||
|
||||
if (auth()->user()->can('update reward')) {
|
||||
if (auth()->user()->can('update reward') && $row->redemptions_count === 0) {
|
||||
$actions .= view('components.actions.table.edit-modal', [
|
||||
'id' => $row->hash,
|
||||
'method' => 'update',
|
||||
@ -60,6 +94,6 @@ public function columns(): array
|
||||
|
||||
public function builder(): Builder
|
||||
{
|
||||
return Reward::select('id', 'name', 'points', 'stock');
|
||||
return Reward::select('id', 'name', 'points', 'stock', 'category', 'is_show', 'start_date', 'end_date')->withCount('redemptions');
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Forms\Studio\Loyalty;
|
||||
|
||||
use App\Enums\IsShow;
|
||||
use App\Enums\RewardCategory;
|
||||
use App\Models\Reward;
|
||||
use App\Rules\UnsignedInteger;
|
||||
use App\Traits\Media\WithMediaHandler;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Form;
|
||||
|
||||
class RewardForm extends Form
|
||||
@ -20,17 +23,26 @@ class RewardForm extends Form
|
||||
|
||||
public ?string $stock = null;
|
||||
|
||||
public string $how_to_get = '';
|
||||
public int $category = RewardCategory::REGULAR->value;
|
||||
|
||||
public int $is_show = IsShow::SHOW->value;
|
||||
|
||||
public string $start_date = '';
|
||||
|
||||
public ?string $end_date = null;
|
||||
|
||||
public array $thumbnail = [];
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'name' => ['required', 'string', 'max:30'],
|
||||
'points' => ['required', new UnsignedInteger],
|
||||
'stock' => ['nullable', new UnsignedInteger],
|
||||
'how_to_get' => ['required', 'string'],
|
||||
'category' => ['required', Rule::in(RewardCategory::cases())],
|
||||
'is_show' => ['required', Rule::in(IsShow::cases())],
|
||||
'start_date' => ['required', 'date'],
|
||||
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
|
||||
'thumbnail' => 'array',
|
||||
];
|
||||
}
|
||||
@ -41,7 +53,10 @@ public function validationAttributes(): array
|
||||
'name' => 'nama',
|
||||
'points' => 'poin',
|
||||
'stock' => 'stok',
|
||||
'how_to_get' => 'cara mendapatkan',
|
||||
'category' => 'kategori',
|
||||
'is_show' => 'visibilitas',
|
||||
'start_date' => 'tanggal mulai',
|
||||
'end_date' => 'tanggal berakhir',
|
||||
];
|
||||
}
|
||||
|
||||
@ -52,7 +67,10 @@ public function setReward(Reward $reward): void
|
||||
$this->name = $reward->name;
|
||||
$this->points = formatCurrencyNumber($reward->points);
|
||||
$this->stock = formatCurrencyNumber($reward->stock);
|
||||
$this->how_to_get = $reward->how_to_get ?? '';
|
||||
$this->category = $reward->category->value;
|
||||
$this->is_show = $reward->is_show->value;
|
||||
$this->start_date = $reward->start_date;
|
||||
$this->end_date = $reward->end_date;
|
||||
$this->thumbnail = $this->mapMediaCollection($reward->getMedia('thumbnail'));
|
||||
}
|
||||
|
||||
@ -65,7 +83,10 @@ public function store(): void
|
||||
'name' => $this->name,
|
||||
'points' => parseRupiahToInt($this->points),
|
||||
'stock' => parseRupiahToInt($this->stock),
|
||||
'how_to_get' => $this->how_to_get,
|
||||
'category' => $this->category,
|
||||
'is_show' => $this->is_show,
|
||||
'start_date' => $this->start_date,
|
||||
'end_date' => $this->end_date,
|
||||
]);
|
||||
|
||||
$this->uploadMedia($this->thumbnail, $reward, 'thumbnail');
|
||||
@ -81,7 +102,10 @@ public function update(): void
|
||||
'name' => $this->name,
|
||||
'points' => parseRupiahToInt($this->points),
|
||||
'stock' => $this->stock ? parseRupiahToInt($this->stock) : null,
|
||||
'how_to_get' => $this->how_to_get,
|
||||
'category' => $this->category,
|
||||
'is_show' => $this->is_show,
|
||||
'start_date' => $this->start_date,
|
||||
'end_date' => $this->end_date,
|
||||
]);
|
||||
|
||||
$this->syncMedia($this->thumbnail, $this->reward, 'thumbnail');
|
||||
|
||||
134
app/Livewire/Member/RedeemPoint.php
Normal file
134
app/Livewire/Member/RedeemPoint.php
Normal file
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Member;
|
||||
|
||||
use App\Enums\PointRecordType;
|
||||
use App\Enums\RedemptionStatus;
|
||||
use App\Models\Redemption;
|
||||
use App\Models\Reward;
|
||||
use App\Traits\Components\WithConfirmation;
|
||||
use App\Traits\Components\WithToast;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Hadiah')]
|
||||
#[Layout('components.layouts.member')]
|
||||
class RedeemPoint extends Component
|
||||
{
|
||||
use WithConfirmation, WithToast;
|
||||
|
||||
public array $rewards = [];
|
||||
|
||||
public array $userRedemptions = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->rewards = Reward::show()
|
||||
->active()
|
||||
->orderBy('points')
|
||||
->get()
|
||||
->map(function (Reward $reward) {
|
||||
$reward->thumbnail = $reward->getFirstMediaUrl('thumbnail');
|
||||
$reward->category_label = $reward->category->label();
|
||||
|
||||
return $reward;
|
||||
})
|
||||
->toArray();
|
||||
|
||||
$this->userRedemptions = Redemption::with('reward')
|
||||
->where('user_id', auth()->id())
|
||||
->latest()
|
||||
->get()
|
||||
->map(function (Redemption $redemption) {
|
||||
$redemption->status_label = $redemption->status->label();
|
||||
$redemption->status_color = $redemption->status->color();
|
||||
|
||||
return $redemption;
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function redeem(Reward $reward): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$membership = $user->membership;
|
||||
|
||||
if ($membership->reward_points < $reward->points) {
|
||||
$this->toast('Poin Anda tidak cukup untuk menukarkan hadiah ini.', 'Gagal', 'danger');
|
||||
|
||||
Flux::modals()->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($reward->stock !== null && $reward->stock <= 0) {
|
||||
$this->toast('Stok hadiah ini sudah habis.', 'Gagal', 'danger');
|
||||
Flux::modals()->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$redemption = DB::transaction(function () use ($user, $membership, $reward) {
|
||||
// Deduct points from membership
|
||||
$membership->decrement('reward_points', $reward->points);
|
||||
|
||||
// Create point record
|
||||
$user->pointRecords()->create([
|
||||
'change' => $reward->points,
|
||||
'type' => PointRecordType::REWARD,
|
||||
'is_addition' => false,
|
||||
'description' => Str::limit("Redeem: {$reward->name}", 50),
|
||||
]);
|
||||
|
||||
// Create redemption
|
||||
$redemption = Redemption::create([
|
||||
'user_id' => $user->id,
|
||||
'reward_id' => $reward->id,
|
||||
'code' => 'RD-'.Str::upper(Str::random(8)),
|
||||
'points_spent' => $reward->points,
|
||||
'status' => RedemptionStatus::WAITING_PICKUP,
|
||||
'expires_at' => now()->addWeek(),
|
||||
]);
|
||||
|
||||
// Decrement stock if applicable
|
||||
if ($reward->stock !== null) {
|
||||
$reward->decrement('stock');
|
||||
}
|
||||
|
||||
return $redemption;
|
||||
});
|
||||
|
||||
// Update rewards list in real-time
|
||||
$this->rewards = collect($this->rewards)->map(function ($item) use ($reward) {
|
||||
if ($item['id'] === $reward->getRouteKey() && $item['stock'] !== null) {
|
||||
$item['stock']--;
|
||||
}
|
||||
|
||||
return $item;
|
||||
})->toArray();
|
||||
|
||||
// Update user redemptions history in real-time
|
||||
$redemption->load('reward');
|
||||
array_unshift($this->userRedemptions, [
|
||||
...$redemption->toArray(),
|
||||
'status_label' => $redemption->status->label(),
|
||||
'status_color' => $redemption->status->color(),
|
||||
]);
|
||||
|
||||
$this->toast('Hadiah berhasil ditukarkan! Silakan cek tab Riwayat untuk melihat kode penukaran Anda.');
|
||||
|
||||
Flux::modals()->close();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.member.rewards', [
|
||||
'pageTitle' => 'Penukaran Poin',
|
||||
]);
|
||||
}
|
||||
}
|
||||
76
app/Livewire/Studio/Loyalty/RedemptionVerification.php
Normal file
76
app/Livewire/Studio/Loyalty/RedemptionVerification.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Studio\Loyalty;
|
||||
|
||||
use App\Enums\RedemptionStatus;
|
||||
use App\Models\Redemption;
|
||||
use App\Traits\Authorization\WithAuthorization;
|
||||
use App\Traits\Components\WithConfirmation;
|
||||
use App\Traits\Components\WithToast;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Verifikasi Penukaran')]
|
||||
class RedemptionVerification extends Component
|
||||
{
|
||||
use WithAuthorization, WithConfirmation, WithToast;
|
||||
|
||||
public string $searchCode = '';
|
||||
|
||||
public ?Redemption $foundRedemption = null;
|
||||
|
||||
public function search(): void
|
||||
{
|
||||
$this->canOrAbort('view redemption');
|
||||
|
||||
$this->foundRedemption = Redemption::where('code', Str::upper(Str::trim($this->searchCode)))
|
||||
->with(['user', 'user.customer', 'reward'])
|
||||
->first();
|
||||
|
||||
if (! $this->foundRedemption) {
|
||||
$this->toast('Kode penukaran tidak ditemukan.', 'Gagal', 'danger');
|
||||
} else {
|
||||
// Force refresh status if expired
|
||||
$this->foundRedemption->isExpired();
|
||||
}
|
||||
}
|
||||
|
||||
public function verify($id = null): void
|
||||
{
|
||||
$this->canOrAbort('update redemption');
|
||||
|
||||
if ($this->foundRedemption->status !== RedemptionStatus::WAITING_PICKUP) {
|
||||
$this->toast('Penukaran ini sudah diproses sebelumnya.', 'warning');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->foundRedemption->isExpired()) {
|
||||
$this->toast('Kode penukaran ini sudah kadaluarsa.', 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->foundRedemption->update([
|
||||
'status' => RedemptionStatus::PICKED_UP,
|
||||
'verified_at' => now(),
|
||||
'verified_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$this->toast('Penukaran hadiah berhasil diverifikasi!');
|
||||
|
||||
$this->foundRedemption->refresh();
|
||||
|
||||
Flux::modals()->close();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.studio.loyalty.redemption', [
|
||||
'pageTitle' => 'Verifikasi Penukaran',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -44,6 +44,13 @@ public function update(): void
|
||||
{
|
||||
$this->canOrAbort('update reward');
|
||||
|
||||
if ($this->form->reward->redemptions()->exists()) {
|
||||
$this->toast('Hadiah tidak dapat diubah karena sudah ada penukaran.', 'error');
|
||||
Flux::modals()->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->form->update();
|
||||
|
||||
$this->dispatch('refreshDatatable');
|
||||
|
||||
59
app/Models/Redemption.php
Normal file
59
app/Models/Redemption.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\RedemptionStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Veelasky\LaravelHashId\Eloquent\HashableId;
|
||||
|
||||
class Redemption extends Model
|
||||
{
|
||||
use HasFactory, HashableId, SoftDeletes;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => RedemptionStatus::class,
|
||||
'points_spent' => 'int',
|
||||
'expires_at' => 'datetime',
|
||||
'verified_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function reward(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Reward::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function verifier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by');
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
if ($this->status === RedemptionStatus::EXPIRED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->expires_at->isPast()) {
|
||||
if ($this->status === RedemptionStatus::WAITING_PICKUP) {
|
||||
$this->update(['status' => RedemptionStatus::EXPIRED]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,13 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\IsShow;
|
||||
use App\Enums\RewardCategory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
@ -20,6 +25,35 @@ protected function casts(): array
|
||||
return [
|
||||
'points' => 'int',
|
||||
'stock' => 'int',
|
||||
'category' => RewardCategory::class,
|
||||
'is_show' => IsShow::class,
|
||||
];
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function show(Builder $query): void
|
||||
{
|
||||
$query->where('is_show', IsShow::SHOW);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function hide(Builder $query): void
|
||||
{
|
||||
$query->where('is_show', IsShow::HIDDEN);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function active(Builder $query): void
|
||||
{
|
||||
$query->whereDate('start_date', '<=', now())
|
||||
->where(function ($q) {
|
||||
$q->whereNull('end_date')
|
||||
->orWhereDate('end_date', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function redemptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Redemption::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,6 +150,11 @@ public function pushNotifications(): HasMany
|
||||
return $this->hasMany(PushNotification::class);
|
||||
}
|
||||
|
||||
public function redemptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Redemption::class);
|
||||
}
|
||||
|
||||
public function salaryHistories(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalaryHistory::class);
|
||||
@ -159,4 +164,9 @@ public function stockOpnameItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockOpnameItem::class);
|
||||
}
|
||||
|
||||
public function verifiedRedemptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Redemption::class, 'verified_by');
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,6 +103,13 @@ public function boot(): void
|
||||
'match' => 'studio.loyalty.customer.*',
|
||||
'can' => 'view customer',
|
||||
],
|
||||
[
|
||||
'label' => 'Verifikasi Penukaran',
|
||||
'icon' => 'check-badge',
|
||||
'route' => 'studio.loyalty.redemption.index',
|
||||
'match' => 'studio.loyalty.redemption.*',
|
||||
'can' => 'view redemption',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\IsShow;
|
||||
use App\Enums\RewardCategory;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@ -13,10 +15,13 @@ public function up(): void
|
||||
{
|
||||
Schema::create('rewards', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('how_to_get');
|
||||
$table->string('name', 30);
|
||||
$table->unsignedInteger('points');
|
||||
$table->unsignedInteger('stock')->nullable();
|
||||
$table->enum('category', RewardCategory::values())->default(RewardCategory::REGULAR)->comment(RewardCategory::comment());
|
||||
$table->enum('is_show', IsShow::values())->default(IsShow::SHOW)->comment(IsShow::comment());
|
||||
$table->date('start_date');
|
||||
$table->date('end_date')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\RedemptionStatus;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('redemptions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('reward_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('code')->unique();
|
||||
$table->unsignedInteger('points_spent');
|
||||
$table->enum('status', RedemptionStatus::values())->default(RedemptionStatus::WAITING_PICKUP)->comment(RedemptionStatus::comment());
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
$table->foreignId('verified_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('redemptions');
|
||||
}
|
||||
};
|
||||
@ -165,6 +165,9 @@ public function run(): void
|
||||
'view social settings',
|
||||
'update social settings',
|
||||
|
||||
'view redemption',
|
||||
'update redemption',
|
||||
|
||||
// member
|
||||
'view member overview',
|
||||
|
||||
@ -172,6 +175,8 @@ public function run(): void
|
||||
|
||||
'view member membership',
|
||||
|
||||
'view member reward',
|
||||
|
||||
// partner
|
||||
'view partner overview',
|
||||
];
|
||||
@ -194,6 +199,7 @@ public function run(): void
|
||||
|
||||
'view member voucher',
|
||||
'view member membership',
|
||||
'view member reward',
|
||||
]));
|
||||
|
||||
$leader->syncPermissions(array_diff($permissions, [
|
||||
@ -202,6 +208,7 @@ public function run(): void
|
||||
|
||||
'view member voucher',
|
||||
'view member membership',
|
||||
'view member reward',
|
||||
|
||||
'create outlet',
|
||||
'update outlet',
|
||||
@ -298,6 +305,7 @@ public function run(): void
|
||||
|
||||
'view member voucher',
|
||||
'view member membership',
|
||||
'view member reward',
|
||||
|
||||
'view outlet',
|
||||
'create outlet',
|
||||
@ -436,6 +444,8 @@ public function run(): void
|
||||
'view member voucher',
|
||||
|
||||
'view member membership',
|
||||
|
||||
'view member reward',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,9 @@ class="bg-zinc-50 dark:bg-zinc-900 border-r rtl:border-r-0 rtl:border-l border-z
|
||||
<flux:navlist.item icon="user-circle" href="{{ route('member.membership') }}"
|
||||
:current="request()->routeIs('member.membership')" wire:navigate>
|
||||
Membership</flux:navlist.item>
|
||||
<flux:navlist.item icon="check-badge" href="{{ route('member.redeem_point') }}"
|
||||
:current="request()->routeIs('member.redeem_point')" wire:navigate>
|
||||
Penukaran Poin</flux:navlist.item>
|
||||
|
||||
@if (auth()->user()->permission('view studio overview'))
|
||||
<div class="mt-3 mb-1">
|
||||
|
||||
136
resources/views/livewire/member/rewards.blade.php
Normal file
136
resources/views/livewire/member/rewards.blade.php
Normal file
@ -0,0 +1,136 @@
|
||||
<flux:main>
|
||||
<flux:heading size="xl" level="1">{{ $pageTitle }}</flux:heading>
|
||||
|
||||
<div class="mt-6">
|
||||
<div
|
||||
class="w-fit bg-zinc-100 dark:bg-zinc-800/50 p-4 rounded-3xl border border-zinc-200 dark:border-white/5 flex items-center gap-4">
|
||||
|
||||
<div class="size-10 rounded-2xl bg-zinc-200 dark:bg-zinc-700 flex items-center justify-center">
|
||||
<flux:icon.star class="size-5 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Poin Tersedia</div>
|
||||
<div class="text-xl sm:text-2xl font-black text-zinc-800 dark:text-white leading-none">
|
||||
{{ formatCurrencyNumber(auth()->user()->membership?->reward_points) }}
|
||||
<span class="text-xs font-bold opacity-40 uppercase tracking-tighter">Poin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:tab.group>
|
||||
<flux:tabs variant="segmented" class="mt-6">
|
||||
<flux:tab icon="gift" name="redeem-point">Tukar Poin</flux:tab>
|
||||
<flux:tab icon="clock" name="history">Riwayat</flux:tab>
|
||||
</flux:tabs>
|
||||
|
||||
<flux:tab.panel name="redeem-point">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4 sm:gap-6">
|
||||
@foreach ($rewards as $reward)
|
||||
<div class="relative group h-[180px] sm:h-[200px] rounded-3xl transition-all duration-300 overflow-hidden"
|
||||
style="-webkit-mask-image: radial-gradient(circle at 0px 50%, transparent 12px, black 13px); mask-image: radial-gradient(circle at 0px 50%, transparent 12px, black 13px);">
|
||||
|
||||
<div class="absolute inset-0 z-0">
|
||||
@if ($reward['thumbnail'])
|
||||
<img src="{{ $reward['thumbnail'] }}" alt="{{ $reward['name'] }}"
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700">
|
||||
@else
|
||||
<div
|
||||
class="w-full h-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
|
||||
<flux:icon.gift class="size-16 text-zinc-200 dark:text-zinc-700" />
|
||||
</div>
|
||||
@endif
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-black/95 via-black/40 to-transparent">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative h-full p-5 sm:p-6 flex flex-col justify-between z-10">
|
||||
<div>
|
||||
<div
|
||||
class="text-[10px] font-bold text-white/50 uppercase tracking-widest mb-1 truncate">
|
||||
{{ $reward['category_label'] }}
|
||||
</div>
|
||||
<div class="text-2xl sm:text-3xl text-white/70 italic leading-tight">
|
||||
{{ formatCurrencyNumber($reward['points']) }}
|
||||
<span class="text-sm font-bold opacity-60 uppercase tracking-tight">Poin</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-white/70 font-bold mt-1 text-base sm:text-lg truncate drop-shadow-md">
|
||||
{{ $reward['name'] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 z-20 flex items-end justify-center opacity-0 group-hover:opacity-100 sm:translate-y-2 sm:group-hover:translate-y-0 transition-all duration-300 pointer-events-none sm:pointer-events-auto">
|
||||
<div class="w-full px-5 sm:px-6 pb-5 sm:pb-6 pointer-events-auto">
|
||||
<flux:modal.trigger name="redeem-confirmation">
|
||||
<flux:button variant="primary" color="zinc"
|
||||
wire:click="$dispatch('fn:confirmAction', { id: '{{ $reward['id'] }}', target: 'redeem' })"
|
||||
icon="ticket" class="w-full text-dark backdrop-blur-md">
|
||||
Tukarkan Sekarang
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($reward['stock'] !== null)
|
||||
<div class="absolute top-4 right-4">
|
||||
<span
|
||||
class="text-[9px] font-bold text-white/40 border border-white/10 px-2 py-0.5 rounded-full uppercase tracking-tighter backdrop-blur-sm bg-zinc-600">
|
||||
Stok: {{ $reward['stock'] }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:tab.panel>
|
||||
|
||||
<flux:tab.panel name="history">
|
||||
@if (!empty($userRedemptions))
|
||||
<flux:card>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>Hadiah</flux:table.column>
|
||||
<flux:table.column>Kode</flux:table.column>
|
||||
<flux:table.column>Poin Dipakai</flux:table.column>
|
||||
<flux:table.column>Status</flux:table.column>
|
||||
<flux:table.column>Kadaluarsa</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@foreach ($userRedemptions as $redemption)
|
||||
<flux:table.row>
|
||||
<flux:table.cell>{{ $redemption['reward']['name'] }}</flux:table.cell>
|
||||
<flux:table.cell>{{ $redemption['code'] }}</flux:table.cell>
|
||||
<flux:table.cell>{{ formatCurrencyNumber($redemption['points_spent']) }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge color="{{ $redemption['status_color'] }}" size="sm"
|
||||
inset="top bottom">
|
||||
{{ $redemption['status_label'] }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
{{ formatDateLocalized($redemption['expires_at'], 'l, d M Y H:i') }}
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</flux:card>
|
||||
@endif
|
||||
</flux:tab.panel>
|
||||
</flux:tab.group>
|
||||
</div>
|
||||
|
||||
@include('components.modals.confirmation', [
|
||||
'modalName' => 'redeem-confirmation',
|
||||
'modalTitle' => 'Tukarkan Hadiah?',
|
||||
'modalMessage' => 'Apakah Anda yakin ingin menukarkan poin Anda dengan hadiah ini?',
|
||||
'buttonVariant' => 'primary',
|
||||
'buttonColor' => 'zinc',
|
||||
'buttonText' => 'Ya, Tukarkan',
|
||||
])
|
||||
</flux:main>
|
||||
@ -30,7 +30,7 @@ class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap
|
||||
</flux:heading>
|
||||
|
||||
<flux:text class="mb-1">
|
||||
Min. Belanja {{ $voucher['min_purchase'] }} • {{ $voucher['tier_name'] }}
|
||||
Min. Belanja {{ $voucher['min_purchase'] }}
|
||||
</flux:text>
|
||||
|
||||
<div x-data="{ textToCopy: '{{ $voucher['code'] }}', copied: false }"
|
||||
@ -68,6 +68,7 @@ class="inline-block px-2 py-1 border border-emerald-300 dark:border-emerald-700
|
||||
@endif
|
||||
</div>
|
||||
</flux:main>
|
||||
|
||||
@assets
|
||||
<script src="https://unpkg.com/@lottiefiles/dotlottie-wc@0.8.5/dist/dotlottie-wc.js" type="module"></script>
|
||||
@endassets
|
||||
|
||||
114
resources/views/livewire/studio/loyalty/redemption.blade.php
Normal file
114
resources/views/livewire/studio/loyalty/redemption.blade.php
Normal file
@ -0,0 +1,114 @@
|
||||
<flux:main>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<flux:card>
|
||||
<div class="flex gap-4">
|
||||
<flux:input label="Masukkan Kode Penukaran" placeholder="Contoh: RD-ABC12345" wire:model="searchCode"
|
||||
autofocus autocomplete="off" wire:keydown.enter="search" class="flex-1" />
|
||||
<div class="flex items-end">
|
||||
<flux:button wire:click="search" variant="primary">Cari</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
@if ($foundRedemption)
|
||||
<flux:card class="mt-6">
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<flux:heading size="lg">Detail Penukaran</flux:heading>
|
||||
<flux:subheading>Informasi lengkap terkait kode <span
|
||||
class="font-bold uppercase">{{ $foundRedemption->code }}</span></flux:subheading>
|
||||
</div>
|
||||
<flux:badge color="{{ $foundRedemption->status->color() }}" variant="solid" size="lg">
|
||||
{{ $foundRedemption->status->label() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">Member
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:avatar :name="$foundRedemption?->user?->customer?->name" size="sm" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ $foundRedemption?->user?->customer?->name }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">{{ $foundRedemption?->user?->customer?->email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">Hadiah</div>
|
||||
<div class="text-sm font-medium text-primary-600">{{ $foundRedemption->reward->name }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ $foundRedemption->points_spent }} Poin</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">Waktu
|
||||
Penukaran</div>
|
||||
<div class="text-sm">{{ formatDateLocalized($foundRedemption->created_at, 'l, d M Y H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">Batas Waktu
|
||||
</div>
|
||||
<div class="text-sm {{ $foundRedemption->isExpired() ? 'text-red-500 font-bold' : '' }}">
|
||||
{{ formatDateLocalized($foundRedemption->expires_at, 'l, d M Y H:i') }}
|
||||
@if ($foundRedemption->isExpired())
|
||||
(Kadaluarsa)
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($foundRedemption->status === \App\Enums\RedemptionStatus::VERIFIED)
|
||||
<div
|
||||
class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">
|
||||
Diverifikasi Oleh</div>
|
||||
<div class="text-sm">{{ $foundRedemption->verifier?->employee?->full_name ?? 'Sistem' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-1">Waktu
|
||||
Verifikasi</div>
|
||||
<div class="text-sm">
|
||||
{{ formatDateLocalized($foundRedemption->verified_at, 'l, d M Y H:i') ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($foundRedemption->status === \App\Enums\RedemptionStatus::WAITING_PICKUP && !$foundRedemption->isExpired())
|
||||
<div class="pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<flux:modal.trigger name="verify-confirmation">
|
||||
<flux:button
|
||||
wire:click="$dispatch('fn:confirmAction', { id: '{{ $foundRedemption->id }}', target: 'verify' })"
|
||||
variant="primary" icon="check-badge">
|
||||
Verifikasi Sekarang
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@include('components.modals.confirmation', [
|
||||
'modalName' => 'verify-confirmation',
|
||||
'modalTitle' => 'Verifikasi Penukaran?',
|
||||
'modalMessage' => 'Apakah Anda yakin ingin memverifikasi penukaran hadiah ini?',
|
||||
'buttonVariant' => 'primary',
|
||||
'buttonColor' => 'zinc',
|
||||
'buttonText' => 'Ya, Verifikasi',
|
||||
])
|
||||
</flux:main>
|
||||
@ -36,12 +36,25 @@
|
||||
<flux:error name="form.name" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Poin <span class="text-red-500 ms-1">*</span></flux:label>
|
||||
<flux:input placeholder="300" x-mask:dynamic="$money($input, ',')" wire:model="form.points"
|
||||
autocomplete="off" />
|
||||
<flux:error name="form.points" />
|
||||
</flux:field>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Poin <span class="text-red-500 ms-1">*</span></flux:label>
|
||||
<flux:input placeholder="300" x-mask:dynamic="$money($input, ',')" wire:model="form.points"
|
||||
autocomplete="off" />
|
||||
<flux:error name="form.points" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:label>Kategori <span class="text-red-500 ms-1">*</span></flux:label>
|
||||
<flux:select variant="listbox" placeholder="Pilih kategori" wire:model="form.category">
|
||||
@foreach (\App\Enums\RewardCategory::cases() as $category)
|
||||
<flux:select.option value="{{ $category->value }}">{{ $category->label() }}
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="form.category" />
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Stok </flux:label>
|
||||
@ -52,12 +65,29 @@
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>Cara Mendapatkan <span class="text-red-500 ms-1">*</span></flux:label>
|
||||
<flux:editor wire:model="form.how_to_get" placeholder="..." autocomplete="off"
|
||||
class="**:data-[slot=content]:min-h-[100px]!" />
|
||||
<flux:error name="form.how_to_get" />
|
||||
<flux:label>Visibilitas <span class="text-red-500 ms-1">*</span></flux:label>
|
||||
<flux:radio.group wire:model="form.is_show" variant="buttons" class="w-full *:flex-1">
|
||||
@foreach (\App\Enums\IsShow::cases() as $item)
|
||||
<flux:radio value="{{ $item->value }}">
|
||||
{{ $item->label() }}
|
||||
</flux:radio>
|
||||
@endforeach
|
||||
</flux:radio.group>
|
||||
<flux:error name="form.is_show" />
|
||||
</flux:field>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<flux:field>
|
||||
<flux:label>Tanggal Mulai <span class="text-red-500 ms-1">*</span></flux:label>
|
||||
<flux:date-picker with-today wire:model="form.start_date" placeholder="Pilih Tanggal"
|
||||
autocomplete="off" locale="id-ID" />
|
||||
<flux:error name="form.start_date" />
|
||||
</flux:field>
|
||||
|
||||
<flux:date-picker with-today label="Tanggal Selesai" wire:model="form.end_date"
|
||||
placeholder="Pilih Tanggal" autocomplete="off" locale="id-ID" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium">Thumbnail</h3>
|
||||
<div class="dropzone-wrapper">
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
use App\Livewire\Member\Membership;
|
||||
use App\Livewire\Member\Overview;
|
||||
use App\Livewire\Member\RedeemPoint;
|
||||
use App\Livewire\Member\Voucher;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@ -12,4 +13,5 @@
|
||||
Route::get('overview', Overview::class)->name('overview');
|
||||
Route::get('vouchers', Voucher::class)->name('voucher');
|
||||
Route::get('memberships', Membership::class)->name('membership');
|
||||
Route::get('redeem-points', RedeemPoint::class)->name('redeem_point');
|
||||
});
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
use App\Livewire\Studio\Information\Faq as FaqComponent;
|
||||
use App\Livewire\Studio\Information\PriceRequest as PriceRequestComponent;
|
||||
use App\Livewire\Studio\Loyalty\Customer as CustomerComponent;
|
||||
use App\Livewire\Studio\Loyalty\RedemptionVerification;
|
||||
use App\Livewire\Studio\Loyalty\Reward as RewardComponent;
|
||||
use App\Livewire\Studio\Loyalty\Tier as TierComponent;
|
||||
use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate;
|
||||
@ -99,6 +100,8 @@
|
||||
Route::prefix('rewards')->name('reward.')->group(function () {
|
||||
Route::get('/', RewardComponent::class)->name('index')->middleware('can:view reward');
|
||||
});
|
||||
|
||||
Route::get('/redemptions', RedemptionVerification::class)->name('redemption.index')->middleware('can:view redemption');
|
||||
});
|
||||
|
||||
Route::prefix('catalog')->name('studio.catalog.')->group(function () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user