feat: Implement category image management and display on the homepage, and streamline perfume feature rendering.

This commit is contained in:
Yoga Pangestu 2025-12-20 15:21:50 +07:00
parent 77c16168e3
commit 93d86a30c0
8 changed files with 147 additions and 115 deletions

View File

@ -5,13 +5,15 @@
use App\Models\Category;
use App\Traits\Datatable\WithConfiguration;
use App\Traits\Datatable\WithPrependColumn;
use App\Traits\Media\WithMediaHandler;
use Illuminate\Database\Eloquent\Builder;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\ImageColumn;
class CategoriesTable extends DataTableComponent
{
use WithConfiguration, WithPrependColumn;
use WithConfiguration, WithMediaHandler, WithPrependColumn;
protected $model = Category::class;
@ -20,6 +22,13 @@ public function columns(): array
return [
Column::make('Nama', 'name')->searchable(),
ImageColumn::make('Gambar')
->location(fn ($row) => optional($row->getMedia('image')->first())->getUrl() ?? asset('assets/images/logo.png'))
->attributes(fn ($row) => [
'style' => 'width: 50px; height: 50px;',
'alt' => $row->name,
]),
Column::make('Aksi')
->label(function ($row) {
$actions = '';

View File

@ -3,6 +3,7 @@
namespace App\Livewire\Forms\Studio\Catalog;
use App\Models\Category;
use App\Traits\Media\WithMediaHandler;
use App\Traits\Utilities\WithSortOrder;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
@ -10,7 +11,7 @@
class CategoryForm extends Form
{
use WithSortOrder;
use WithMediaHandler, WithSortOrder;
public ?Category $category = null;
@ -18,11 +19,14 @@ class CategoryForm extends Form
public ?string $description = null;
public array $image = [];
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:20', Rule::unique('categories', 'name')->whereNull('deleted_at')->ignore($this->category)],
'description' => 'nullable',
'image' => 'array',
];
}
@ -31,6 +35,7 @@ public function validationAttributes(): array
return [
'name' => 'nama',
'description' => 'deskripsi',
'image' => 'gambar',
];
}
@ -40,6 +45,8 @@ public function setCategory(Category $category): void
$this->name = $category->name;
$this->description = $category->description;
$this->image = $this->mapMediaCollection($category->getMedia('image'));
}
public function store(): void
@ -50,11 +57,13 @@ public function store(): void
// Increment all existing categories' sort_order
Category::query()->increment('sort_order');
Category::create([
$category = Category::create([
'name' => $this->name,
'description' => $this->description,
'sort_order' => 1,
]);
$this->uploadMedia($this->image, $category, 'image');
});
}
@ -62,10 +71,15 @@ public function update(): void
{
$this->validate();
$this->category->update([
'name' => $this->name,
'description' => $this->description,
]);
DB::transaction(function () {
$this->category->update([
'name' => $this->name,
'description' => $this->description,
]);
$this->syncMedia($this->image, $this->category, 'image');
$this->uploadMedia($this->image, $this->category, 'image');
});
}
public function delete(): void

View File

@ -3,6 +3,7 @@
namespace App\Livewire\Home;
use App\Models\Brand;
use App\Models\Category;
use App\Models\ChooseUs;
use App\Models\Customer;
use App\Models\Faq;
@ -13,12 +14,15 @@
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('components.layouts.home', [
'title' => 'Beranda',
])]
class Index extends Component
{
use WithPagination;
public array $bestSellingPerfumes = [];
public array $brands = [];
@ -65,13 +69,7 @@ public function mount(): void
$this->whyChooseUs = ChooseUs::orderBy('sort_order')->get()->toArray();
$this->perfumeFeatures = PerfumeFeature::orderBy('sort_order')
->get()
->map(fn (PerfumeFeature $perfumeFeature) => [
'name' => $perfumeFeature->name,
'description' => $perfumeFeature->description,
])
->toArray();
$this->perfumeFeatures = PerfumeFeature::orderBy('sort_order')->get()->toArray();
$this->faqs = Faq::orderBy('sort_order')
->get()
@ -100,6 +98,14 @@ public function render(): View
{
return view('livewire.home.index', [
'pageTitle' => 'Beranda',
'categories' => Category::orderBy('sort_order')
->paginate(12)
->through(function (Category $category) {
$category->image = $category->getFirstMediaUrl('image')
?: asset('assets/images/logo.png');
return $category;
}),
]);
}
}

View File

@ -6,13 +6,15 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
use Veelasky\LaravelHashId\Eloquent\HashableId;
class Category extends Model
class Category extends Model implements HasMedia
{
use HasFactory, HashableId, HasSlug, SoftDeletes;
use HasFactory, HashableId, HasSlug, InteractsWithMedia, SoftDeletes;
protected $guarded = ['id'];

View File

@ -43,6 +43,23 @@
transform: translateY(0);
transition: all .4s ease;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.category-item {
opacity: 0;
animation: fadeUp 0.45s ease forwards;
}
</style>
@endassets
@ -126,11 +143,34 @@ function animate() {
link.addEventListener('click', e => {
e.preventDefault();
links.forEach(l => l.classList.remove('bg-home-foreground'));
links.forEach(l => l.classList.add('bg-gray-400'));
const targetPage = new URL(link.href).searchParams.get('page') || '1';
e.target.classList.remove('bg-gray-400');
e.target.classList.add('bg-home-foreground');
// Update all dots (mobile & desktop)
links.forEach(l => {
const lPage = new URL(l.href).searchParams.get('page') || '1';
const isActive = lPage === targetPage;
const isMobile = l.closest('nav').classList.contains(
'lg:hidden');
if (isActive) {
l.classList.add('bg-home-primary');
if (isMobile) {
l.classList.add('w-8');
l.classList.remove('w-2', 'bg-gray-300');
} else {
l.classList.add('h-12');
l.classList.remove('h-6', 'bg-gray-200');
}
} else {
l.classList.remove('bg-home-primary', 'w-8', 'h-12');
if (isMobile) {
l.classList.add('bg-gray-300', 'w-2');
} else {
l.classList.add('bg-gray-200', 'h-6');
}
}
});
fetch(link.href)
.then(res => res.text())

View File

@ -1,77 +1,55 @@
@php
$accordions = [
[
'title' => 'Aroma Premium Tahan Lama',
'content' =>
'Setiap varian dirancang menggunakan konsentrat berkualitas tinggi sehingga wanginya bertahan sepanjang hari, cocok dipakai kerja maupun aktivitas luar.',
],
[
'title' => 'Formula Aman untuk Kulit',
'content' =>
'Parfum kami menggunakan bahan yang lebih lembut di kulit sehingga nyaman digunakan setiap hari tanpa rasa perih atau iritasi.',
],
[
'title' => 'Pilihan Aroma Sangat Beragam',
'content' =>
'Mulai dari floral, fresh, woody, oriental hingga parfum harian — semua tersedia untuk menyesuaikan gaya dan kepribadianmu.',
],
[
'title' => 'Harga Terjangkau Kualitas Premium',
'content' =>
'Nikmati parfum berkelas tanpa perlu mengeluarkan biaya tinggi. Value terbaik untuk kebutuhan harian maupun koleksi pribadi.',
],
];
@endphp
<section class="bg-white py-16 lg:py-24 px-4 md:px-8 lg:px-24 xl:px-32 font-sans text-home-foreground overflow-hidden">
<div class="max-w-7xl mx-auto">
<div class="grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
<figure class="w-full relative order-2 lg:order-1">
<div class="absolute -inset-4 bg-home-primary/5 rounded-3xl -z-10 transform -rotate-2"></div>
<img src="https://images.unsplash.com/photo-1523275335684-37898b6baf30?q=80"
alt="Botol Parfum Elegan"
<img src="https://images.pexels.com/photos/4659793/pexels-photo-4659793.jpeg" alt="Botol Parfum Elegan"
class="w-full h-[400px] lg:h-[600px] object-cover rounded-2xl shadow-lg">
</figure>
<article class="space-y-8 order-1 lg:order-2" x-data="{ activeAccordion: 0 }">
<header>
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-home-foreground leading-tight mb-6">
Keunggulan Produk Kami.
</h2>
<p class="text-base md:text-lg text-home-foreground/70 leading-relaxed">
Setiap parfum dibuat dengan standar tinggi agar kamu mendapatkan kualitas terbaik dan pengalaman wangi yang tak terlupakan.
Setiap parfum dibuat dengan standar tinggi agar kamu mendapatkan kualitas terbaik dan pengalaman
wangi yang tak terlupakan.
</p>
</header>
<div class="bg-white rounded-2xl p-6 md:p-8 shadow-sm border border-gray-100">
@foreach ($accordions as $index => $acc)
@foreach ($perfumeFeatures as $index => $perfumeFeature)
<div class="border-b border-gray-100 last:border-0">
<button
<button
@click="activeAccordion = (activeAccordion === {{ $index }} ? null : {{ $index }})"
class="w-full flex justify-between items-center py-5 text-left group focus:outline-none transition-all duration-300"
:aria-expanded="activeAccordion === {{ $index }}">
<span class="text-lg font-bold transition-colors duration-300"
:class="activeAccordion === {{ $index }} ? 'text-home-primary' : 'text-home-foreground group-hover:text-home-primary'">
{{ $acc['title'] }}
:class="activeAccordion === {{ $index }} ? 'text-home-primary' :
'text-home-foreground group-hover:text-home-primary'">
{{ $perfumeFeature['name'] }}
</span>
<span class="ml-4 flex-shrink-0 transition-transform duration-300 w-8 h-8 flex items-center justify-center rounded-full"
:class="activeAccordion === {{ $index }} ? 'rotate-180 bg-home-primary text-white' : 'bg-gray-100 text-gray-400 group-hover:bg-home-primary/10 group-hover:text-home-primary'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
<span
class="ml-4 flex-shrink-0 transition-transform duration-300 w-8 h-8 flex items-center justify-center rounded-full"
:class="activeAccordion === {{ $index }} ? 'rotate-180 bg-home-primary text-white' :
'bg-gray-100 text-gray-400 group-hover:bg-home-primary/10 group-hover:text-home-primary'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
<div x-show="activeAccordion === {{ $index }}"
x-collapse
x-cloak
<div x-show="activeAccordion === {{ $index }}" x-collapse x-cloak
class="overflow-hidden text-home-foreground/70 text-base leading-relaxed">
<div class="pb-5">
{{ $acc['content'] }}
{{ $perfumeFeature['description'] }}
</div>
</div>
</div>
@ -82,4 +60,4 @@ class="overflow-hidden text-home-foreground/70 text-base leading-relaxed">
</div>
</div>
</section>
</section>

View File

@ -1,38 +1,3 @@
@php
$industries = [
['name' => 'AROMA FLORAL'],
['name' => 'AROMA FRESH'],
['name' => 'AROMA WOODY'],
['name' => 'AROMA ORIENTAL'],
['name' => 'PARFUM HARIAN'],
['name' => 'PARFUM KERJA'],
['name' => 'PARFUM MALAM'],
['name' => 'PARFUM PREMIUM'],
['name' => 'AROMA SWEET'],
['name' => 'AROMA FRUITY'],
['name' => 'AROMA MUSKY'],
['name' => 'LIMITED EDITION'],
['name' => 'AROMA CITRUS'],
['name' => 'AROMA AQUA'],
['name' => 'AROMA SPICY'],
['name' => 'PARFUM SPORT'],
['name' => 'PARFUM CLASSIC'],
['name' => 'AROMA GREEN'],
['name' => 'AROMA TEA'],
['name' => 'PARFUM SUMMER'],
['name' => 'AROMA TROPICAL'],
['name' => 'PARFUM LUXURY'],
['name' => 'AROMA EARTHY'],
];
$perPage = 12;
$page = request('page', 1);
$total = count($industries);
$start = ($page - 1) * $perPage;
$currentItems = array_slice($industries, $start, $perPage);
$totalPages = ceil($total / $perPage);
@endphp
<section class="bg-home-foreground py-16 lg:py-24 px-4 md:px-8 lg:px-24 xl:px-32 relative overflow-hidden">
<div class="max-w-7xl mx-auto">
@ -51,23 +16,20 @@
<div id="category-container" class="fade-enter-active">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
@foreach ($currentItems as $industry)
@foreach ($categories as $category)
<article
class="group flex flex-col items-center justify-center py-10 px-4 bg-white rounded-2xl border border-gray-100 hover:border-home-primary hover:shadow-xl hover:shadow-home-primary/5 hover:-translate-y-1 transition-all duration-300 cursor-pointer h-full">
class="category-item group flex flex-col items-center justify-center py-10 px-4 bg-white rounded-2xl border border-gray-100 hover:border-home-primary hover:shadow-xl hover:shadow-home-primary/5 hover:-translate-y-1 transition-all duration-300 cursor-pointer h-full"
wire:key="category-{{ $category['id'] }}">
<div
class="w-14 h-14 mb-4 rounded-full bg-gray-50 flex items-center justify-center group-hover:bg-home-primary transition-colors duration-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor"
class="w-7 h-7 text-gray-400 group-hover:text-white transition-colors duration-300">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
class="w-24 h-24 mb-4 rounded-full bg-gray-50 flex items-center justify-center group-hover:bg-home-primary transition-colors duration-300 overflow-hidden">
<img src="{{ $category['image'] }}" alt="{{ $category['name'] }}"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110">
</div>
<h3
class="text-[11px] md:text-xs font-bold tracking-widest text-gray-400 uppercase group-hover:text-home-primary transition-colors duration-300 text-center">
{{ $industry['name'] }}
{{ $category['name'] }}
</h3>
</article>
@endforeach
@ -76,18 +38,18 @@ class="text-[11px] md:text-xs font-bold tracking-widest text-gray-400 uppercase
<nav class="mt-12 flex flex-col items-center space-y-4 lg:hidden" aria-label="Pagination Mobile">
<div class="flex items-center gap-2">
@for ($i = 1; $i <= $totalPages; $i++)
@for ($i = 1; $i <= $categories->lastPage(); $i++)
<a href="?page={{ $i }}" aria-label="Halaman {{ $i }}"
class="w-2 h-2 rounded-full transition-all duration-300 {{ $i == $page ? 'bg-home-primary w-8' : 'bg-gray-300 hover:bg-gray-400' }}"></a>
class="pagination-link w-2 h-2 rounded-full transition-all duration-300 {{ $i == $categories->currentPage() ? 'bg-home-primary w-8' : 'bg-gray-300 hover:bg-gray-400' }}"></a>
@endfor
</div>
</nav>
<nav class="hidden lg:flex absolute top-0 -right-16 h-full items-center" aria-label="Pagination Desktop">
<div class="flex flex-col gap-3">
@for ($i = 1; $i <= $totalPages; $i++)
<a href="?page={{ $i }}" aria-label="Halaman {{ $i }}"
class="w-1 rounded-full transition-all duration-300 {{ $i == $page ? 'bg-home-primary h-12' : 'bg-gray-200 h-6 hover:bg-gray-300' }}"></a>
@for ($i = 1; $i <= $categories->lastPage(); $i++)
<a href="{{ $categories->url($i) }}"
class="pagination-link w-1 rounded-full transition-all duration-300 {{ $i == $categories->currentPage() ? 'bg-home-primary h-12' : 'bg-gray-200 h-6 hover:bg-gray-300' }}"></a>
@endfor
</div>
</nav>

View File

@ -47,6 +47,27 @@
placeholder="Aromaterapi merupakan wewangian yang dapat membantu meredakan stress dan meredakan perasaan sakit kepala."
auotocomplete="off" class="**:data-[slot=content]:min-h-[100px]!" />
<div class="space-y-3">
<h3 class="text-sm font-medium">Gambar</h3>
<div class="dropzone-wrapper">
<livewire:dropzone wire:model="form.image" :rules="['image', 'mimes:png,jpeg', 'max:10420']" :max-files="1" :key="'image'"
:files="$form->image ?? []" />
@error('form.image')
<div role="alert" aria-live="polite" aria-atomic="true"
class="mt-3 text-sm font-medium text-red-500 dark:text-red-400">
<svg class="shrink-0 [:where(&amp;)]:size-5 inline" data-flux-icon=""
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true" data-slot="icon">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clip-rule="evenodd"></path>
</svg>
{{ $message }}
</div>
@enderror
</div>
</div>
<div class="flex">
<flux:spacer />
<flux:button variant="primary" class="sm:w-auto cursor-pointer" wire:click="{{ $method }}">