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\WithConfiguration;
|
||||||
use App\Traits\Datatable\WithPrependColumn;
|
use App\Traits\Datatable\WithPrependColumn;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
use Rappasoft\LaravelLivewireTables\Views\Column;
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
|
||||||
@ -33,11 +34,44 @@ public function columns(): array
|
|||||||
->searchable()
|
->searchable()
|
||||||
->sortable(),
|
->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')
|
Column::make('Aksi')
|
||||||
->label(function ($row) {
|
->label(function ($row) {
|
||||||
$actions = '';
|
$actions = '';
|
||||||
|
|
||||||
if (auth()->user()->can('update reward')) {
|
if (auth()->user()->can('update reward') && $row->redemptions_count === 0) {
|
||||||
$actions .= view('components.actions.table.edit-modal', [
|
$actions .= view('components.actions.table.edit-modal', [
|
||||||
'id' => $row->hash,
|
'id' => $row->hash,
|
||||||
'method' => 'update',
|
'method' => 'update',
|
||||||
@ -60,6 +94,6 @@ public function columns(): array
|
|||||||
|
|
||||||
public function builder(): Builder
|
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;
|
namespace App\Livewire\Forms\Studio\Loyalty;
|
||||||
|
|
||||||
|
use App\Enums\IsShow;
|
||||||
|
use App\Enums\RewardCategory;
|
||||||
use App\Models\Reward;
|
use App\Models\Reward;
|
||||||
use App\Rules\UnsignedInteger;
|
use App\Rules\UnsignedInteger;
|
||||||
use App\Traits\Media\WithMediaHandler;
|
use App\Traits\Media\WithMediaHandler;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Form;
|
use Livewire\Form;
|
||||||
|
|
||||||
class RewardForm extends Form
|
class RewardForm extends Form
|
||||||
@ -20,17 +23,26 @@ class RewardForm extends Form
|
|||||||
|
|
||||||
public ?string $stock = null;
|
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 array $thumbnail = [];
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:100'],
|
'name' => ['required', 'string', 'max:30'],
|
||||||
'points' => ['required', new UnsignedInteger],
|
'points' => ['required', new UnsignedInteger],
|
||||||
'stock' => ['nullable', 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',
|
'thumbnail' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -41,7 +53,10 @@ public function validationAttributes(): array
|
|||||||
'name' => 'nama',
|
'name' => 'nama',
|
||||||
'points' => 'poin',
|
'points' => 'poin',
|
||||||
'stock' => 'stok',
|
'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->name = $reward->name;
|
||||||
$this->points = formatCurrencyNumber($reward->points);
|
$this->points = formatCurrencyNumber($reward->points);
|
||||||
$this->stock = formatCurrencyNumber($reward->stock);
|
$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'));
|
$this->thumbnail = $this->mapMediaCollection($reward->getMedia('thumbnail'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +83,10 @@ public function store(): void
|
|||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'points' => parseRupiahToInt($this->points),
|
'points' => parseRupiahToInt($this->points),
|
||||||
'stock' => parseRupiahToInt($this->stock),
|
'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');
|
$this->uploadMedia($this->thumbnail, $reward, 'thumbnail');
|
||||||
@ -81,7 +102,10 @@ public function update(): void
|
|||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'points' => parseRupiahToInt($this->points),
|
'points' => parseRupiahToInt($this->points),
|
||||||
'stock' => $this->stock ? parseRupiahToInt($this->stock) : null,
|
'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');
|
$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');
|
$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->form->update();
|
||||||
|
|
||||||
$this->dispatch('refreshDatatable');
|
$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;
|
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\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Spatie\MediaLibrary\HasMedia;
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
@ -20,6 +25,35 @@ protected function casts(): array
|
|||||||
return [
|
return [
|
||||||
'points' => 'int',
|
'points' => 'int',
|
||||||
'stock' => '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);
|
return $this->hasMany(PushNotification::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function redemptions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Redemption::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function salaryHistories(): HasMany
|
public function salaryHistories(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(SalaryHistory::class);
|
return $this->hasMany(SalaryHistory::class);
|
||||||
@ -159,4 +164,9 @@ public function stockOpnameItems(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(StockOpnameItem::class);
|
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.*',
|
'match' => 'studio.loyalty.customer.*',
|
||||||
'can' => 'view 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
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\IsShow;
|
||||||
|
use App\Enums\RewardCategory;
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@ -13,10 +15,13 @@ public function up(): void
|
|||||||
{
|
{
|
||||||
Schema::create('rewards', function (Blueprint $table) {
|
Schema::create('rewards', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('name');
|
$table->string('name', 30);
|
||||||
$table->text('how_to_get');
|
|
||||||
$table->unsignedInteger('points');
|
$table->unsignedInteger('points');
|
||||||
$table->unsignedInteger('stock')->nullable();
|
$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->timestamps();
|
||||||
$table->softDeletes();
|
$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',
|
'view social settings',
|
||||||
'update social settings',
|
'update social settings',
|
||||||
|
|
||||||
|
'view redemption',
|
||||||
|
'update redemption',
|
||||||
|
|
||||||
// member
|
// member
|
||||||
'view member overview',
|
'view member overview',
|
||||||
|
|
||||||
@ -172,6 +175,8 @@ public function run(): void
|
|||||||
|
|
||||||
'view member membership',
|
'view member membership',
|
||||||
|
|
||||||
|
'view member reward',
|
||||||
|
|
||||||
// partner
|
// partner
|
||||||
'view partner overview',
|
'view partner overview',
|
||||||
];
|
];
|
||||||
@ -194,6 +199,7 @@ public function run(): void
|
|||||||
|
|
||||||
'view member voucher',
|
'view member voucher',
|
||||||
'view member membership',
|
'view member membership',
|
||||||
|
'view member reward',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$leader->syncPermissions(array_diff($permissions, [
|
$leader->syncPermissions(array_diff($permissions, [
|
||||||
@ -202,6 +208,7 @@ public function run(): void
|
|||||||
|
|
||||||
'view member voucher',
|
'view member voucher',
|
||||||
'view member membership',
|
'view member membership',
|
||||||
|
'view member reward',
|
||||||
|
|
||||||
'create outlet',
|
'create outlet',
|
||||||
'update outlet',
|
'update outlet',
|
||||||
@ -298,6 +305,7 @@ public function run(): void
|
|||||||
|
|
||||||
'view member voucher',
|
'view member voucher',
|
||||||
'view member membership',
|
'view member membership',
|
||||||
|
'view member reward',
|
||||||
|
|
||||||
'view outlet',
|
'view outlet',
|
||||||
'create outlet',
|
'create outlet',
|
||||||
@ -436,6 +444,8 @@ public function run(): void
|
|||||||
'view member voucher',
|
'view member voucher',
|
||||||
|
|
||||||
'view member membership',
|
'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') }}"
|
<flux:navlist.item icon="user-circle" href="{{ route('member.membership') }}"
|
||||||
:current="request()->routeIs('member.membership')" wire:navigate>
|
:current="request()->routeIs('member.membership')" wire:navigate>
|
||||||
Membership</flux:navlist.item>
|
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'))
|
@if (auth()->user()->permission('view studio overview'))
|
||||||
<div class="mt-3 mb-1">
|
<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:heading>
|
||||||
|
|
||||||
<flux:text class="mb-1">
|
<flux:text class="mb-1">
|
||||||
Min. Belanja {{ $voucher['min_purchase'] }} • {{ $voucher['tier_name'] }}
|
Min. Belanja {{ $voucher['min_purchase'] }}
|
||||||
</flux:text>
|
</flux:text>
|
||||||
|
|
||||||
<div x-data="{ textToCopy: '{{ $voucher['code'] }}', copied: false }"
|
<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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</flux:main>
|
</flux:main>
|
||||||
|
|
||||||
@assets
|
@assets
|
||||||
<script src="https://unpkg.com/@lottiefiles/dotlottie-wc@0.8.5/dist/dotlottie-wc.js" type="module"></script>
|
<script src="https://unpkg.com/@lottiefiles/dotlottie-wc@0.8.5/dist/dotlottie-wc.js" type="module"></script>
|
||||||
@endassets
|
@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:error name="form.name" />
|
||||||
</flux:field>
|
</flux:field>
|
||||||
|
|
||||||
<flux:field>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<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"
|
<flux:field>
|
||||||
autocomplete="off" />
|
<flux:label>Poin <span class="text-red-500 ms-1">*</span></flux:label>
|
||||||
<flux:error name="form.points" />
|
<flux:input placeholder="300" x-mask:dynamic="$money($input, ',')" wire:model="form.points"
|
||||||
</flux:field>
|
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:field>
|
||||||
<flux:label>Stok </flux:label>
|
<flux:label>Stok </flux:label>
|
||||||
@ -52,12 +65,29 @@
|
|||||||
</flux:field>
|
</flux:field>
|
||||||
|
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>Cara Mendapatkan <span class="text-red-500 ms-1">*</span></flux:label>
|
<flux:label>Visibilitas <span class="text-red-500 ms-1">*</span></flux:label>
|
||||||
<flux:editor wire:model="form.how_to_get" placeholder="..." autocomplete="off"
|
<flux:radio.group wire:model="form.is_show" variant="buttons" class="w-full *:flex-1">
|
||||||
class="**:data-[slot=content]:min-h-[100px]!" />
|
@foreach (\App\Enums\IsShow::cases() as $item)
|
||||||
<flux:error name="form.how_to_get" />
|
<flux:radio value="{{ $item->value }}">
|
||||||
|
{{ $item->label() }}
|
||||||
|
</flux:radio>
|
||||||
|
@endforeach
|
||||||
|
</flux:radio.group>
|
||||||
|
<flux:error name="form.is_show" />
|
||||||
</flux:field>
|
</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">
|
<div class="space-y-3">
|
||||||
<h3 class="text-sm font-medium">Thumbnail</h3>
|
<h3 class="text-sm font-medium">Thumbnail</h3>
|
||||||
<div class="dropzone-wrapper">
|
<div class="dropzone-wrapper">
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Livewire\Member\Membership;
|
use App\Livewire\Member\Membership;
|
||||||
use App\Livewire\Member\Overview;
|
use App\Livewire\Member\Overview;
|
||||||
|
use App\Livewire\Member\RedeemPoint;
|
||||||
use App\Livewire\Member\Voucher;
|
use App\Livewire\Member\Voucher;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@ -12,4 +13,5 @@
|
|||||||
Route::get('overview', Overview::class)->name('overview');
|
Route::get('overview', Overview::class)->name('overview');
|
||||||
Route::get('vouchers', Voucher::class)->name('voucher');
|
Route::get('vouchers', Voucher::class)->name('voucher');
|
||||||
Route::get('memberships', Membership::class)->name('membership');
|
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\Faq as FaqComponent;
|
||||||
use App\Livewire\Studio\Information\PriceRequest as PriceRequestComponent;
|
use App\Livewire\Studio\Information\PriceRequest as PriceRequestComponent;
|
||||||
use App\Livewire\Studio\Loyalty\Customer as CustomerComponent;
|
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\Reward as RewardComponent;
|
||||||
use App\Livewire\Studio\Loyalty\Tier as TierComponent;
|
use App\Livewire\Studio\Loyalty\Tier as TierComponent;
|
||||||
use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate;
|
use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate;
|
||||||
@ -99,6 +100,8 @@
|
|||||||
Route::prefix('rewards')->name('reward.')->group(function () {
|
Route::prefix('rewards')->name('reward.')->group(function () {
|
||||||
Route::get('/', RewardComponent::class)->name('index')->middleware('can:view reward');
|
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 () {
|
Route::prefix('catalog')->name('studio.catalog.')->group(function () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user