feat: Implement member point redemption system with studio verification and enhanced reward management.

This commit is contained in:
Yoga Pangestu 2025-12-19 20:41:04 +07:00
parent e07a2386cf
commit 4febd74674
21 changed files with 820 additions and 22 deletions

View 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',
};
}
}

View 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',
};
}
}

View File

@ -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');
}
}

View File

@ -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');

View 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',
]);
}
}

View 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',
]);
}
}

View File

@ -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
View 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;
}
}

View File

@ -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);
}
}

View File

@ -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');
}
}

View File

@ -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',
],
],
],
[

View File

@ -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();
});

View File

@ -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');
}
};

View File

@ -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',
]);
}
}

View File

@ -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">

View 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>

View File

@ -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

View 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>

View File

@ -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">

View File

@ -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');
});

View File

@ -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 () {