feat: Implement dedicated perfume product pages and add a new reward tier table for loyalty features.

This commit is contained in:
Yoga Pangestu 2025-12-20 09:44:47 +07:00
parent d2578fff34
commit 0800503453
8 changed files with 93 additions and 1 deletions

View File

@ -10,6 +10,7 @@
use Illuminate\Support\Facades\Blade;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\ArrayColumn;
class RewardsTable extends DataTableComponent
{
@ -42,6 +43,13 @@ public function columns(): array
->label(fn ($row, $column) => Blade::render('<flux:badge color="'.$row->is_show->color().'">'.$row->is_show->label().'</flux:badge>'))
->html(),
ArrayColumn::make('Tier')
->data(
fn ($value, $row) => $row->tiers->pluck('name')->toArray() ?: ['Semua Tier']
)
->outputFormat(fn ($index, $value) => Blade::render('<span class="text-xs text-gray-400 border border-gray-400 rounded px-1">'.$value.'</span>'))
->flexRow(['class' => 'gap-2 flex-wrap']),
Column::make('Tanggal')
->label(function ($row) {
$startDate = formatDateLocalized($row->start_date);
@ -94,6 +102,8 @@ public function columns(): array
public function builder(): Builder
{
return Reward::select('id', 'name', 'points', 'stock', 'category', 'is_show', 'start_date', 'end_date')->withCount('redemptions');
return Reward::select('id', 'name', 'points', 'stock', 'category', 'is_show', 'start_date', 'end_date')
->with('tiers:id,name')
->withCount('redemptions');
}
}

View File

@ -31,6 +31,8 @@ class RewardForm extends Form
public ?string $end_date = null;
public array $tier_ids = [];
public array $thumbnail = [];
public function rules(): array
@ -43,6 +45,8 @@ public function rules(): array
'is_show' => ['required', Rule::in(IsShow::cases())],
'start_date' => ['required', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'tier_ids' => ['required', 'array', 'min:1'],
'tier_ids.*' => Rule::exists('tiers', 'id'),
'thumbnail' => 'array',
];
}
@ -57,6 +61,8 @@ public function validationAttributes(): array
'is_show' => 'visibilitas',
'start_date' => 'tanggal mulai',
'end_date' => 'tanggal berakhir',
'tier_ids' => 'tier',
'tier_ids.*' => 'tier',
];
}
@ -71,6 +77,7 @@ public function setReward(Reward $reward): void
$this->is_show = $reward->is_show->value;
$this->start_date = $reward->start_date;
$this->end_date = $reward->end_date;
$this->tier_ids = $reward->tiers->pluck('id')->toArray();
$this->thumbnail = $this->mapMediaCollection($reward->getMedia('thumbnail'));
}
@ -89,6 +96,8 @@ public function store(): void
'end_date' => $this->end_date,
]);
$reward->tiers()->sync($this->tier_ids);
$this->uploadMedia($this->thumbnail, $reward, 'thumbnail');
});
}
@ -108,6 +117,8 @@ public function update(): void
'end_date' => $this->end_date,
]);
$this->reward->tiers()->sync($this->tier_ids);
$this->syncMedia($this->thumbnail, $this->reward, 'thumbnail');
$this->uploadMedia($this->thumbnail, $this->reward, 'thumbnail');
});

View File

@ -28,8 +28,13 @@ class RedeemPoint extends Component
public function mount(): void
{
$userTierId = auth()->user()->membership->tier_id;
$this->rewards = Reward::show()
->active()
->whereHas('tiers', function ($query) use ($userTierId) {
$query->where('id', $userTierId);
})
->orderBy('points')
->get()
->map(function (Reward $reward) {
@ -58,6 +63,14 @@ public function redeem(Reward $reward): void
$user = auth()->user();
$membership = $user->membership;
// Check if reward is available for user's tier
if (! $reward->tiers()->where('tiers.id', $membership->tier_id)->exists()) {
$this->toast('Hadiah ini tidak tersedia untuk tier Anda.', 'Gagal', 'danger');
Flux::modals()->close();
return;
}
if ($membership->reward_points < $reward->points) {
$this->toast('Poin Anda tidak cukup untuk menukarkan hadiah ini.', 'Gagal', 'danger');

View File

@ -4,6 +4,7 @@
use App\Livewire\Forms\Studio\Loyalty\RewardForm;
use App\Models\Reward as RewardModel;
use App\Models\Tier;
use App\Traits\Authorization\WithAuthorization;
use App\Traits\Components\WithCloseModal;
use App\Traits\Components\WithConfirmation;
@ -27,6 +28,13 @@ class Reward extends Component
public string $modalTitle = '';
public $tiers = [];
public function mount(): void
{
$this->tiers = Tier::orderBy('min_spending')->pluck('name', 'id')->toArray();
}
public function create(): void
{
$this->canOrAbort('create reward');

View File

@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
@ -52,6 +53,11 @@ protected function active(Builder $query): void
});
}
public function tiers(): BelongsToMany
{
return $this->belongsToMany(Tier::class);
}
public function redemptions(): HasMany
{
return $this->hasMany(Redemption::class);

View File

@ -49,6 +49,11 @@ public function vouchers(): BelongsToMany
return $this->belongsToMany(Voucher::class);
}
public function rewards(): BelongsToMany
{
return $this->belongsToMany(Reward::class);
}
public static function getHighestTier()
{
return self::orderByDesc('min_spending')->first();

View File

@ -0,0 +1,29 @@
<?php
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('reward_tier', function (Blueprint $table) {
$table->foreignId('reward_id')->constrained()->cascadeOnDelete();
$table->foreignId('tier_id')->constrained()->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reward_tier');
}
};

View File

@ -88,6 +88,16 @@
placeholder="Pilih Tanggal" autocomplete="off" locale="id-ID" />
</div>
<flux:field>
<flux:label>Tier <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select placeholder="Pilih Tier" wire:model="form.tier_ids" multiple searchable variant="listbox">
@foreach ($tiers as $key => $value)
<flux:select.option value="{{ $key }}">{{ $value }}</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.tier_ids" />
</flux:field>
<div class="space-y-3">
<h3 class="text-sm font-medium">Thumbnail</h3>
<div class="dropzone-wrapper">