Compare commits

...

6 Commits

Author SHA1 Message Date
Yoga Pangestu
b905c0a2c6 feat: update EmployeeAdvance status values and related logic, modify notifications accordingly 2026-08-13 17:36:55 +07:00
Yoga Pangestu
7d21a1e6db feat: add cart context and functionality, update welcome page with new components and styles
- Implemented CartContext for managing cart state, including adding, removing, and updating items.
- Added CartDrawer component to display cart items.
- Updated AppHeader to include cart item count.
- Enhanced welcome page with new layout, improved product image handling, and updated call-to-action buttons.
- Refactored homepage data types to accommodate new features and removed unused properties.
2026-08-13 17:19:56 +07:00
Yoga Pangestu
f7c9891787 Update application icons with new designs for better visual appeal 2026-08-13 14:05:50 +07:00
Yoga Pangestu
a468f6c4ff feat: add new permissions for attendance and profit margin, update return values in analysis component 2026-08-13 14:01:29 +07:00
Yoga Pangestu
1fe95d66fc feat: update app name to 'DST Collection' and reorganize imports in AppHeader component 2026-08-13 13:54:10 +07:00
Yoga Pangestu
1c9532199c feat: add is_featured attribute and toggle functionality for products 2026-08-13 12:02:49 +07:00
40 changed files with 792 additions and 645 deletions

View File

@ -46,9 +46,9 @@ ### `product_categories` → Category (Pivot)
- Relations: category(BelongsTo→Category), product(BelongsTo→Product)
### `products` → Product
`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `rejection_reason`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: status(ProductStatus)
- Scopes: active(), draft(), inactive(), pending(), rejected()
`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `is_featured`(bool,default:false) `rejection_reason`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: status(ProductStatus), is_featured(bool)
- Scopes: active(), draft(), featured(), inactive(), pending(), rejected()
- Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant)
### `product_variants` → ProductVariant
@ -288,7 +288,7 @@ ## Enums
|------|--------|---------|
| `CashTransactionType` | deposit, expense, transfer, withdrawal | cash_transactions.type |
| `CuttingStatus` | in_progress, completed, cancelled | cuttings.status |
| `EmployeeAdvanceStatus` | pending, approved, rejected, paid, cancelled | employee_advances.status |
| `EmployeeAdvanceStatus` | pending, disbursed, partial, repaid, rejected | employee_advances.status |
| `EmploymentStatus` | full_time, part_time, contract, internship, resigned | employees.employment_status |
| `Gender` | male, female | user_profiles.gender |
| `LeaveRequestStatus` | pending, approved, rejected, cancelled | leave_requests.status |
@ -336,6 +336,7 @@ ### Master
| `RawMaterialVariantRequest` | `variant` | `required, string, max:200` | ✅ varchar(200) |
| `RawMaterialVariantRequest` | `price` | `required, integer, min:0` | ✅ uint |
| `ProductRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
| `ProductRequest` | `is_featured` | `nullable, boolean` | ✅ bool |
| `ProductVariantRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
### Finance

View File

@ -8,20 +8,20 @@ enum EmployeeAdvanceStatus: string
{
use HasValues;
case APPROVED = 'approved';
case CANCELLED = 'cancelled';
case PAID = 'paid';
case DISBURSED = 'disbursed';
case PARTIAL = 'partial';
case PENDING = 'pending';
case REJECTED = 'rejected';
case REPAID = 'repaid';
public function label(): string
{
return match ($this) {
self::APPROVED => 'Disetujui',
self::CANCELLED => 'Dibatalkan',
self::PAID => 'Dibayar',
self::DISBURSED => 'Dikeluarkan',
self::PARTIAL => 'Dicicil',
self::PENDING => 'Menunggu',
self::REJECTED => 'Ditolak',
self::REPAID => 'Lunas',
};
}
}

View File

@ -71,6 +71,7 @@ enum Permission: string
case PRODUCTS_UPDATE = 'products.update';
case PRODUCTS_DELETE = 'products.delete';
case PRODUCTS_TOGGLE_STATUS = 'products.toggle_status';
case PRODUCTS_TOGGLE_FEATURED = 'products.toggle_featured';
case PRODUCTS_TRANSFER_STOCK = 'products.transfer_stock';
case PRODUCTS_VIEW_STOCK_MUTATIONS = 'products.view_stock_mutations';

View File

@ -24,11 +24,11 @@ public function index(PaginatedRequest $request): Response
return Inertia::render('admin/master/product/index', [
'products' => $this->service->paginated(
...$request->validatedWithDefaults(),
filters: $request->only(['status', 'stock', 'category', 'name']),
filters: $request->only(['status', 'stock', 'category', 'name', 'featured']),
),
'categories' => $this->categoryService->getAll(),
'productNames' => $this->service->getNames(),
'filters' => $request->only(['status', 'stock', 'category', 'name']),
'filters' => $request->only(['status', 'stock', 'category', 'name', 'featured']),
]);
}
@ -87,6 +87,16 @@ public function toggleStatus(Product $product): RedirectResponse
return back();
}
public function toggleFeatured(Product $product): RedirectResponse
{
$this->service->toggleFeatured($product);
$featured = $product->fresh()->is_featured;
Inertia::flash('toast', ['type' => 'success', 'message' => $featured ? 'Produk berhasil ditampilkan di halaman depan.' : 'Produk berhasil disembunyikan dari halaman depan.']);
return back();
}
public function approve(Product $product): RedirectResponse
{
$this->service->approve($product);

View File

@ -33,10 +33,12 @@ public function __invoke(Request $request): Response
$productsQuery = Product::select(['id', 'name', 'slug', 'description', 'status'])
->active()
->featured()
->with([
'categories:id,name,slug',
'productVariants:id,product_id,name,stock',
'productVariants.productPrices:id,variant_id,type,price',
'productVariants.media',
]);
if ($search !== '') {
@ -52,9 +54,23 @@ public function __invoke(Request $request): Response
});
}
$products = Inertia::scroll(
fn () => $productsQuery->orderBy('created_at', 'desc')->paginate(12)
);
$s3Service = $this->s3Service;
$products = Inertia::scroll(function () use ($productsQuery, $s3Service) {
$paginator = $productsQuery->orderBy('created_at', 'desc')->paginate(12);
$paginator->getCollection()->each(function ($product) use ($s3Service) {
$product->productVariants->each(function ($variant) use ($s3Service) {
$media = $variant->getMedia('images');
$variant->photo_urls = $media->map(
fn ($m) => str_starts_with($m->getPath(), 'http')
? $m->getPath()
: $s3Service->getTemporaryUrl($m->getPath(), 60)
)->toArray();
});
});
return $paginator;
});
$galleryImages = array_map(
fn ($key) => str_starts_with($key, 'http') ? $key : $this->s3Service->getTemporaryUrl($key, 60),
@ -71,44 +87,13 @@ public function __invoke(Request $request): Response
'facebookUrl' => $socialMedia->facebook_url,
'tiktokUrl' => $socialMedia->tiktok_url,
'homepage' => [
'hero_badge' => $homepage->hero_badge,
'hero_title_line1' => $homepage->hero_title_line1,
'hero_title_line2' => $homepage->hero_title_line2,
'hero_title_highlight' => $homepage->hero_title_highlight,
'hero_description' => $homepage->hero_description,
'hero_cta_primary_text' => $homepage->hero_cta_primary_text,
'hero_cta_secondary_text' => $homepage->hero_cta_secondary_text,
'hero_image_url' => $homepage->hero_image_url
? (str_starts_with($homepage->hero_image_url, 'http') ? $homepage->hero_image_url : $this->s3Service->getTemporaryUrl($homepage->hero_image_url, 60))
: null,
'hero_bg_text_left' => $homepage->hero_bg_text_left,
'hero_bg_text_right' => $homepage->hero_bg_text_right,
'scroll_hashtag' => $homepage->scroll_hashtag,
'scroll_tagline' => $homepage->scroll_tagline,
'catalog_badge' => $homepage->catalog_badge,
'catalog_title' => $homepage->catalog_title,
'catalog_description' => $homepage->catalog_description,
'catalog_search_placeholder' => $homepage->catalog_search_placeholder,
'gallery_badge' => $homepage->gallery_badge,
'gallery_title' => $homepage->gallery_title,
'gallery_description' => $homepage->gallery_description,
'gallery_images' => $galleryImages,
'order_guide_badge' => $homepage->order_guide_badge,
'order_guide_title' => $homepage->order_guide_title,
'order_guide_description' => $homepage->order_guide_description,
'order_steps' => $homepage->order_steps,
'about_badge' => $homepage->about_badge,
'about_title' => $homepage->about_title,
'about_image_url' => $homepage->about_image_url
? (str_starts_with($homepage->about_image_url, 'http') ? $homepage->about_image_url : $this->s3Service->getTemporaryUrl($homepage->about_image_url, 60))
: null,
'about_features' => $homepage->about_features,
'contact_badge' => $homepage->contact_badge,
'contact_title' => $homepage->contact_title,
'contact_description' => $homepage->contact_description,
'contact_form_title' => $homepage->contact_form_title,
'footer_description' => $homepage->footer_description,
'footer_copyright' => $homepage->footer_copyright,
'gallery_images' => $galleryImages,
],
'categories' => $categories,
'products' => $products,
@ -117,8 +102,8 @@ public function __invoke(Request $request): Response
'category' => $category,
],
'seo' => [
'title' => $system->app_name.' - '.$homepage->hero_badge,
'description' => $homepage->hero_description,
'title' => $system->app_name.' - Koleksi Segar 2026',
'description' => 'Selamat datang di '.$system->app_name.'. Temukan keanggunan motif batik modern dan setelan pakaian santai berkualitas premium.',
'image' => url('/assets/logo.png'),
'url' => url('/'),
],

View File

@ -37,6 +37,7 @@ public function rules(): array
],
'description' => ['nullable', 'string'],
'status' => ['nullable', Rule::in(ProductStatus::values())],
'is_featured' => ['nullable', 'boolean'],
'category_ids' => ['required', 'array', 'min:1'],
'category_ids.*' => [Rule::exists('categories', 'id')],
'use_same_price' => ['nullable', 'boolean'],
@ -64,6 +65,7 @@ public function attributes(): array
'name' => 'nama produk',
'description' => 'deskripsi',
'status' => 'status',
'is_featured' => 'ditampilkan di halaman depan',
'category_ids' => 'kategori',
'variants' => 'varian',
'variants.*.name' => 'nama varian',

View File

@ -75,21 +75,21 @@ protected function statusLabel(): Attribute
}
#[Scope]
protected function approved(Builder $query): void
protected function disbursed(Builder $query): void
{
$query->where('status', EmployeeAdvanceStatus::APPROVED);
$query->where('status', EmployeeAdvanceStatus::DISBURSED);
}
#[Scope]
protected function cancelled(Builder $query): void
protected function partial(Builder $query): void
{
$query->where('status', EmployeeAdvanceStatus::CANCELLED);
$query->where('status', EmployeeAdvanceStatus::PARTIAL);
}
#[Scope]
protected function paid(Builder $query): void
protected function repaid(Builder $query): void
{
$query->where('status', EmployeeAdvanceStatus::PAID);
$query->where('status', EmployeeAdvanceStatus::REPAID);
}
#[Scope]

View File

@ -26,6 +26,7 @@ protected function casts(): array
{
return [
'status' => ProductStatus::class,
'is_featured' => 'boolean',
];
}
@ -55,6 +56,12 @@ protected function draft(Builder $query): void
$query->where('status', ProductStatus::DRAFT);
}
#[Scope]
protected function featured(Builder $query): void
{
$query->where('is_featured', true);
}
#[Scope]
protected function inactive(Builder $query): void
{

View File

@ -63,13 +63,13 @@ public function store(array $data): EmployeeAdvance
public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance
{
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) {
if ($employeeAdvance->status === EmployeeAdvanceStatus::REPAID) {
throw ValidationException::withMessages([
'amount' => 'Kasbon yang sudah dibayar tidak dapat diedit.',
'amount' => 'Kasbon yang sudah dikembalikan tidak dapat diedit.',
]);
}
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) {
if ($employeeAdvance->status === EmployeeAdvanceStatus::DISBURSED && $employeeAdvance->cash_transaction_id) {
$oldAmount = $employeeAdvance->amount;
$newAmount = $data['amount'];
@ -113,7 +113,7 @@ public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeA
public function destroy(EmployeeAdvance $employeeAdvance): bool
{
return DB::transaction(function () use ($employeeAdvance) {
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) {
if ($employeeAdvance->status === EmployeeAdvanceStatus::DISBURSED && $employeeAdvance->cash_transaction_id) {
$this->creditCash(
$employeeAdvance->amount,
'Pembatalan kasbon: '.$employeeAdvance->description,
@ -122,7 +122,7 @@ public function destroy(EmployeeAdvance $employeeAdvance): bool
$employeeAdvance->cashTransaction()->delete();
}
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) {
if ($employeeAdvance->status === EmployeeAdvanceStatus::REPAID || $employeeAdvance->status === EmployeeAdvanceStatus::PARTIAL) {
$this->creditCash(
$employeeAdvance->amount,
'Pembatalan kasbon: '.$employeeAdvance->description,
@ -159,7 +159,7 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
$employeeAdvance->update([
'cash_transaction_id' => $cashTransaction->id,
'status' => EmployeeAdvanceStatus::APPROVED,
'status' => EmployeeAdvanceStatus::DISBURSED,
'verified_by_id' => auth()->id(),
'verified_at' => now(),
]);
@ -169,8 +169,8 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Kasbon Disetujui',
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.',
title: 'Kasbon Dikeluarkan',
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui dan dikeluarkan'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.employee-advances.index'),
additionalUser: $employeeAdvance->employee->user ?? null,
);
@ -208,7 +208,7 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva
$employeeAdvance->update([
'paid_amount' => $newPaidAmount,
'status' => $isFullyPaid ? EmployeeAdvanceStatus::PAID : $employeeAdvance->status,
'status' => $isFullyPaid ? EmployeeAdvanceStatus::REPAID : EmployeeAdvanceStatus::PARTIAL,
'paid_by_id' => $isFullyPaid ? auth()->id() : $employeeAdvance->paid_by_id,
'paid_at' => $isFullyPaid ? now() : $employeeAdvance->paid_at,
]);
@ -216,13 +216,13 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva
return $employeeAdvance;
});
$notificationBody = $employeeAdvance->status === EmployeeAdvanceStatus::PAID
? 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.'
$notificationBody = $employeeAdvance->status === EmployeeAdvanceStatus::REPAID
? 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dikembalikan lunas'.' oleh '.auth()->user()->full_name.'.'
: 'Pembayaran kasbon sebesar Rp '.number_format($amount, 0, ',', '.').' oleh '.auth()->user()->full_name.'. Sisa: Rp '.number_format($employeeAdvance->amount - $employeeAdvance->paid_amount, 0, ',', '.').'.';
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon',
title: $employeeAdvance->status === EmployeeAdvanceStatus::REPAID ? 'Kasbon Dikembalikan' : 'Pembayaran Kasbon',
body: $notificationBody,
url: route('admin.finance.employee-advances.index'),
additionalUser: $employeeAdvance->employee->user ?? null,

View File

@ -35,7 +35,7 @@ public function getNames(): Collection
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = Product::query()
->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason'])
->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason', 'is_featured'])
->with([
'categories:id,name',
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
@ -49,6 +49,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $categoryId);
}))
->when($filters['featured'] ?? null, fn ($q, $featured) => $q->where('is_featured', $featured === 'true'))
->when(($filters['stock'] ?? null) === 'empty', function ($q) {
$q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM product_variants WHERE product_variants.product_id = products.id AND product_variants.deleted_at IS NULL) = 0');
})
@ -433,6 +434,15 @@ public function toggleStatus(Product $product): void
]);
}
public function toggleFeatured(Product $product): void
{
$this->assertNotPending($product);
$product->update([
'is_featured' => ! $product->is_featured,
]);
}
public function approve(Product $product): void
{
$product->update([

View File

@ -3,6 +3,7 @@
namespace App\Services;
use App\Enums\CashTransactionType;
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
@ -286,7 +287,7 @@ public function getExpenseSummary(?string $startDate, ?string $endDate): array
$expenseQuery = Expense::query();
$this->applyDateFilter($expenseQuery, $startDate, $endDate, 'expenses.created_at');
$advanceQuery = EmployeeAdvance::where('status', 'paid');
$advanceQuery = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID);
$this->applyDateFilter($advanceQuery, $startDate, $endDate, 'employee_advances.created_at');
$purchaseQuery = Purchase::query();
@ -326,7 +327,7 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate): array
->get()
->keyBy('month');
$advanceMonthly = EmployeeAdvance::where('status', 'paid');
$advanceMonthly = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID);
$this->applyDateFilter($advanceMonthly, $startDate, $endDate, 'employee_advances.created_at');
$advanceByMonth = (clone $advanceMonthly)->toBase()

View File

@ -113,7 +113,7 @@ public function getExpenseSummary(): array
->first();
$advanceTotal = EmployeeAdvance::whereDate('created_at', $today)
->approved()
->disbursed()
->selectRaw('COALESCE(SUM(amount), 0) as total')
->first();

View File

@ -6,73 +6,11 @@
class HomepageSettings extends Settings
{
public string $hero_badge;
public string $hero_title_line1;
public string $hero_title_line2;
public string $hero_title_highlight;
public string $hero_description;
public string $hero_cta_primary_text;
public string $hero_cta_secondary_text;
public ?string $hero_image_url;
public string $hero_bg_text_left;
public string $hero_bg_text_right;
public string $scroll_hashtag;
public string $scroll_tagline;
public string $catalog_badge;
public string $catalog_title;
public string $catalog_description;
public string $catalog_search_placeholder;
public string $gallery_badge;
public string $gallery_title;
public string $gallery_description;
public array $gallery_images;
public string $order_guide_badge;
public string $order_guide_title;
public string $order_guide_description;
public array $order_steps;
public string $about_badge;
public string $about_title;
public ?string $about_image_url;
public array $about_features;
public string $contact_badge;
public string $contact_title;
public string $contact_description;
public string $contact_form_title;
public string $footer_description;
public string $footer_copyright;
public array $gallery_images;
public static function group(): string
{

View File

@ -17,7 +17,7 @@ public function definition(): array
'paid_amount' => 0,
'description' => fake()->sentence(),
'due_date' => fake()->date(),
'status' => fake()->randomElement(['pending', 'approved', 'paid', 'rejected', 'cancelled']),
'status' => fake()->randomElement(['pending', 'disbursed', 'partial', 'repaid', 'rejected']),
];
}
}

View File

@ -16,6 +16,7 @@ public function up(): void
$table->string('slug', 200)->unique();
$table->text('description')->nullable();
$table->string('status', 20)->default(ProductStatus::ACTIVE->value);
$table->boolean('is_featured')->default(false);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();

View File

@ -21,7 +21,7 @@ public function run(): void
'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'],
'categories' => ['view', 'create', 'update', 'delete'],
'customers' => ['view', 'create', 'update', 'delete'],
'products' => ['view', 'create', 'update', 'delete', 'toggle_status', 'transfer_stock', 'view_stock_mutations'],
'products' => ['view', 'create', 'update', 'delete', 'toggle_status', 'toggle_featured', 'transfer_stock', 'view_stock_mutations'],
'stocks' => ['view'],
'orders' => ['view', 'create', 'update', 'delete', 'send', 'complete', 'cancel'],
'cuttings' => ['view', 'create', 'update', 'delete', 'complete'],
@ -70,6 +70,7 @@ public function run(): void
'admin-toko' => array_values(array_filter($allPermissions, function ($p) {
return in_array($p, [
'dashboard.view',
'dashboard.attendance',
'dashboard.revenue',
'dashboard.expense',
'dashboard.orders_channel',
@ -88,6 +89,7 @@ public function run(): void
'analysis.profit_orders',
'analysis.profit_hpp',
'analysis.profit_gross',
'analysis.profit_margin',
'analysis.top_customers',
'analysis.top_products',
'analysis.top_suppliers',
@ -124,6 +126,7 @@ public function run(): void
'products.update',
'products.delete',
'products.toggle_status',
'products.toggle_featured',
'products.transfer_stock',
'products.view_stock_mutations',
@ -378,6 +381,7 @@ public function run(): void
'analysis.profit_margin',
'analysis.top_customers',
'analysis.top_products',
'analysis.top_suppliers',
'analysis.marketing_sales',
'employees.view',

View File

@ -8,82 +8,16 @@
public function up(): void
{
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void {
// Hero Section
$blueprint->add('hero_badge', 'Koleksi Segar 2026');
$blueprint->add('hero_title_line1', 'Ekspresikan');
$blueprint->add('hero_title_line2', 'Gaya');
$blueprint->add('hero_title_highlight', 'Segar Anda');
$blueprint->add('hero_description', 'Selamat datang di {app_name}. Temukan keanggunan motif batik modern dan setelan pakaian santai berkualitas premium. Dirancang khusus dengan bahan adem yang menyejukkan aktivitas harian Anda.');
$blueprint->add('hero_cta_primary_text', 'Beli Sekarang');
$blueprint->add('hero_cta_secondary_text', 'Tentang Kami');
$blueprint->add('hero_image_url', 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80');
$blueprint->add('hero_bg_text_left', 'DST');
$blueprint->add('hero_bg_text_right', 'Collection');
// Scroll Indicator
$blueprint->add('scroll_hashtag', '#DSTCollection');
$blueprint->add('scroll_tagline', 'Bahan Adem & Lembut');
// Catalog Section
$blueprint->add('catalog_badge', 'Katalog Eksklusif');
$blueprint->add('catalog_title', 'Koleksi Busana Pilihan');
$blueprint->add('catalog_description', 'Gunakan kategori dan filter di bawah untuk menyesuaikan pencarian busana idaman Anda dengan mudah.');
$blueprint->add('catalog_search_placeholder', 'Cari nama pakaian...');
// Deal Section
$blueprint->add('deal_badge', 'Promo Terbatas');
$blueprint->add('deal_title', 'Penawaran Bulan Ini');
$blueprint->add('deal_description', 'Jangan lewatkan promosi batik dan daster eksklusif kami! Dapatkan potongan harga spesial up to 25% untuk varian daster pilihan dan setelan pakaian modern. Dapatkan kenyamanan ekstra dengan bahan menyejukkan sebelum masa promo habis!');
$blueprint->add('deal_cta_text', 'Jelajahi Produk Diskon');
$blueprint->add('deal_images', [
$blueprint->add('about_image_url', 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80');
$blueprint->add('gallery_images', [
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1509631179647-0177331693ae?w=800&auto=format&fit=crop&q=80',
]);
// Order Guide Section
$blueprint->add('order_guide_badge', 'Langkah Pemesanan');
$blueprint->add('order_guide_title', 'Cara Melakukan Pemesanan');
$blueprint->add('order_guide_description', 'Sistem pemesanan kami sangat mudah dan terhubung langsung via WhatsApp untuk pelayanan cepat dan personal.');
$blueprint->add('order_steps', [
[
'title' => 'Pilih Produk',
'description' => 'Jelajahi pakaian batik dan daster favorit Anda, lalu ketuk "Lihat Detail" untuk memeriksa ukuran.',
],
[
'title' => 'Pilih Varian & Harga',
'description' => 'Tentukan varian ukuran yang diinginkan dan pilih jenis harga (Eceran, Grosir, Agen, dll.).',
],
[
'title' => 'Masukkan Keranjang',
'description' => 'Masukkan ke Keranjang Belanja untuk menampung seluruh daftar pakaian yang ingin Anda beli.',
],
[
'title' => 'Kirim ke WhatsApp',
'description' => 'Klik tombol kirim pesanan, admin kami akan merespons rincian transfer bank dan pengiriman kurir.',
],
]);
// About Section
$blueprint->add('about_badge', 'Tentang Kami');
$blueprint->add('about_title', 'DST Collection');
$blueprint->add('about_image_url', 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80');
$blueprint->add('about_features', [
'Kain Rayon Super Tebal & Menyerap Keringat',
'Motif Eksklusif & Tidak Pasaran',
'Dukungan Penuh Layanan Admin Via WhatsApp',
]);
// Contact Section
$blueprint->add('contact_badge', 'Kontak Kami');
$blueprint->add('contact_title', 'Ada Pertanyaan? Hubungi Kami');
$blueprint->add('contact_description', 'Kami sangat senang mendengarkan pertanyaan Anda terkait spesifikasi produk, ketersediaan grosir, atau kemitraan. Hubungi tim admin kami melalui media di bawah.');
$blueprint->add('contact_form_title', 'Kirim Pesan Cepat');
// Footer
$blueprint->add('footer_description', 'Galeri resmi {app_name}. Pilihan busana lokal premium berpotongan modern dengan kenyamanan menyejukkan.');
$blueprint->add('footer_copyright', '© 2026 {app_name} DST Collection. Hak Cipta Dilindungi.');
});
}
};

View File

@ -8,25 +8,11 @@
public function up(): void
{
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void {
// Delete old deal fields
$blueprint->delete('deal_badge');
$blueprint->delete('deal_title');
$blueprint->delete('deal_description');
$blueprint->delete('deal_cta_text');
$blueprint->delete('deal_images');
// Add new gallery fields
$blueprint->add('gallery_badge', 'Galeri Kami');
$blueprint->add('gallery_title', 'Koleksi Lookbook');
$blueprint->add('gallery_description', 'Intip koleksi lookbook kami untuk inspirasi gaya sehari-hari. Padu padan batik modern dan daster yang nyaman untuk berbagai suasana.');
$blueprint->add('gallery_images', [
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1509631179647-0177331693ae?w=800&auto=format&fit=crop&q=80',
]);
});
}
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

@ -1,15 +1,15 @@
import { createInertiaApp } from '@inertiajs/react';
import { registerSW } from 'virtual:pwa-register';
import { FlashToast } from '@/components/notifications';
import { PWAUpdateToast } from '@/components/notifications';
import { FlashToast, PWAUpdateToast } from '@/components/notifications';
import { Toaster } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { CartProvider } from '@/contexts/cart-context';
import { initializeTheme } from '@/hooks/use-appearance';
import AppLayout from '@/layouts/app-layout';
import AuthLayout from '@/layouts/auth-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { createInertiaApp } from '@inertiajs/react';
import { registerSW } from 'virtual:pwa-register';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const appName = import.meta.env.VITE_APP_NAME || 'DST Collection';
registerSW({
onNeedRefresh() {
@ -26,6 +26,8 @@ createInertiaApp({
switch (true) {
case name === 'admin/manage/cutting/show':
return null;
case name === 'welcome':
return null;
case name.startsWith('auth/'):
return AuthLayout;
case name.startsWith('settings/'):
@ -37,12 +39,14 @@ createInertiaApp({
strictMode: true,
withApp(app) {
return (
<TooltipProvider delayDuration={0}>
<FlashToast />
<PWAUpdateToast />
{app}
<Toaster />
</TooltipProvider>
<CartProvider>
<TooltipProvider delayDuration={0}>
<FlashToast />
<PWAUpdateToast />
{app}
<Toaster />
</TooltipProvider>
</CartProvider>
);
},
progress: {

View File

@ -0,0 +1,113 @@
import { Button } from '@/components/ui/button';
import { useCart } from '@/contexts/cart-context';
import { formatRupiah } from '@/lib/rupiah';
import { formatWhatsAppPhone } from '@/lib/format';
import { Minus, Plus, ShoppingBag, ShoppingCart, Trash2, X } from 'lucide-react';
type CartDrawerProps = {
open: boolean;
onClose: () => void;
contactPhone?: string | null;
};
export default function CartDrawer({ open, onClose, contactPhone }: CartDrawerProps) {
const { items, removeItem, updateQuantity, clearCart, totalItems, totalPrice } = useCart();
if (!open) return null;
const handleCheckout = () => {
if (items.length === 0 || !contactPhone) return;
const lines = items.map((item) => {
const subtotal = item.price * item.quantity;
return `*${item.productName}*\n* Varian: ${item.variantName}\n* Harga: Rp ${formatRupiah(item.price)}\n* QTY : ${item.quantity}\n* Subtotal : Rp ${formatRupiah(subtotal)}`;
});
const message = `Halo min, saya mau order, berikut pesanannya.\n\n${lines.join('\n\n')}\n\nTotal: Rp ${formatRupiah(totalPrice)}\n\nMohon diinformasikan detail pembayaran dan proses selanjutnya ya, Kak.\n\nTerimakasih`;
const waUrl = `https://wa.me/${formatWhatsAppPhone(contactPhone || '')}?text=${encodeURIComponent(message)}`;
window.open(waUrl, '_blank');
};
return (
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div
className="relative w-full max-w-md bg-[#FDFDFC] shadow-2xl flex flex-col animate-in slide-in-from-right duration-300"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-amber-100/30">
<div className="flex items-center gap-2">
<ShoppingBag className="w-5 h-5 text-amber-600" />
<h3 className="text-lg font-serif text-slate-800">Keranjang</h3>
<span className="text-xs text-slate-400">({totalItems} item)</span>
</div>
<div className="flex items-center gap-2">
{items.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearCart} className="text-xs text-red-500 hover:text-red-600 hover:bg-red-50">
<Trash2 className="w-3.5 h-3.5 mr-1" />
Hapus Semua
</Button>
)}
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full hover:bg-slate-100">
<X className="w-5 h-5 text-slate-500" />
</Button>
</div>
</div>
{/* Items */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center space-y-3">
<ShoppingBag className="w-12 h-12 text-slate-200" />
<p className="text-sm text-slate-400">Keranjang kosong</p>
<p className="text-xs text-slate-300">Tambahkan produk untuk mulai berbelanja</p>
</div>
) : (
items.map((item) => (
<div key={item.variantId} className="flex gap-3 p-3 bg-white rounded-xl border border-slate-100 shadow-sm">
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0">
<img src={item.imageUrl} alt={item.productName} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-semibold text-slate-800 truncate">{item.productName}</p>
<p className="text-[10px] text-slate-400">{item.variantName} &middot; {item.priceType === 'retail' ? 'Retail' : 'Grosir'}</p>
<p className="text-sm font-bold text-amber-600">Rp {formatRupiah(item.price)}</p>
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-2 border border-slate-200 rounded-lg px-2 py-0.5">
<Button variant="ghost" size="icon-xs" onClick={() => updateQuantity(item.variantId, item.quantity - 1)}>
<Minus className="w-3 h-3" />
</Button>
<span className="text-xs font-semibold w-5 text-center">{item.quantity}</span>
<Button variant="ghost" size="icon-xs" onClick={() => updateQuantity(item.variantId, item.quantity + 1)}>
<Plus className="w-3 h-3" />
</Button>
</div>
<Button variant="ghost" size="icon-xs" onClick={() => removeItem(item.variantId)} className="text-red-400 hover:text-red-500 hover:bg-red-50">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
))
)}
</div>
{/* Footer */}
{items.length > 0 && (
<div className="border-t border-amber-100/30 p-5 space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-slate-500">Total</span>
<span className="text-xl font-bold text-amber-600">Rp {formatRupiah(totalPrice)}</span>
</div>
<Button asChild className="w-full bg-amber-600 text-white hover:bg-amber-500 py-3.5 text-xs font-bold uppercase tracking-widest rounded-xl transition-all shadow-md">
<a href="#" onClick={(e) => { e.preventDefault(); handleCheckout(); }}>
<ShoppingCart className="w-4 h-4 mr-2" />
Beli Sekarang
</a>
</Button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,42 +1,27 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useCart } from '@/contexts/cart-context';
import { formatRupiah } from '@/lib/rupiah';
import { formatWhatsAppPhone } from '@/lib/format';
import type { Product } from '@/types/homepage';
import { ShoppingBag, ShoppingCart } from 'lucide-react';
import { toast } from 'sonner';
type ProductCardProps = {
product: Product;
onQuickView: (product: Product) => void;
contactPhone?: string | null;
};
const FALLBACK_IMAGE = 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80';
function getProductImage(product: Product): string {
for (const variant of product.product_variants) {
const prices = variant.product_prices;
if (prices.length > 0) {
return `https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80`;
if (variant.photo_urls && variant.photo_urls.length > 0) {
return variant.photo_urls[0];
}
}
const fallbacks: Record<string, string[]> = {
'daster': [
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=800&auto=format&fit=crop&q=80',
],
'setelan-celana': [
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
],
'atasan': [
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
],
'bawahan': [
'https://images.unsplash.com/photo-1583496661160-fb4886b36ca7?w=800&auto=format&fit=crop&q=80',
],
};
const firstCat = product.categories?.[0]?.slug || '';
const categoryUrls = fallbacks[firstCat] || [
'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80',
'https://images.unsplash.com/photo-1434389677669-e08b4cac3105?w=800&auto=format&fit=crop&q=80',
];
return categoryUrls[0];
return FALLBACK_IMAGE;
}
function getProductPriceRange(product: Product): string {
@ -71,46 +56,87 @@ function getProductPriceRange(product: Product): string {
: `Rp ${formatRupiah(min)} - Rp ${formatRupiah(max)}`;
}
export default function ProductCard({ product, onQuickView }: ProductCardProps) {
export default function ProductCard({ product, onQuickView, contactPhone }: ProductCardProps) {
const { addItem } = useCart();
console.log(contactPhone)
const handleAddToCart = () => {
const variant = product.product_variants[0];
if (!variant) return;
const retail = variant.product_prices.find((p) => p.type === 'retail');
const wholesale = variant.product_prices.find((p) => p.type === 'wholesale');
const price = retail?.price ?? wholesale?.price ?? 0;
addItem({
productId: product.id,
productName: product.name,
variantId: variant.id,
variantName: variant.name,
priceType: 'retail',
price,
imageUrl: getProductImage(product),
});
toast.success(`${product.name} ditambahkan ke keranjang`);
};
const variant = product.product_variants[0];
const retail = variant?.product_prices.find((p) => p.type === 'retail');
const price = retail?.price ?? 0;
const waMessage = encodeURIComponent(
`Halo min, saya mau order, berikut pesanannya.\n\n*${product.name}*\n* Varian: ${variant?.name ?? '-'}\n* Harga: Rp ${formatRupiah(price)}\n* QTY : 1\n* Subtotal : Rp ${formatRupiah(price)}\n\nTotal: Rp ${formatRupiah(price)}\n\nMohon diinformasikan detail pembayaran dan proses selanjutnya ya, Kak.\n\nTerimakasih`
);
const waUrl = `https://wa.me/${formatWhatsAppPhone(contactPhone || '')}?text=${waMessage}`;
return (
<div
className="group cursor-pointer"
onClick={() => onQuickView(product)}
>
<div className="cursor-pointer" onClick={() => onQuickView(product)}>
<div className="relative aspect-3/4 rounded-2xl overflow-hidden shadow-md border-2 border-white transition-all duration-300 hover:shadow-xl hover:-translate-y-1">
<img
src={getProductImage(product)}
alt={product.name}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
className="w-full h-full object-cover transition-transform duration-700 hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute bottom-0 left-0 right-0 p-4 translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<span className="text-white text-xs font-semibold uppercase tracking-widest">
Lihat Detail
</span>
</div>
</div>
<div className="mt-4 space-y-1">
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2">
{product.categories.slice(0, 2).map((cat) => (
<span
key={cat.id}
className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full"
>
<Badge key={cat.id} variant="secondary" className="bg-amber-50 text-amber-600 uppercase tracking-wider font-semibold px-2 py-0.5">
{cat.name}
</span>
</Badge>
))}
</div>
<h4 className="text-sm font-semibold text-slate-800 line-clamp-1">
{product.name}
</h4>
<p className="text-xs text-slate-500 line-clamp-2 font-light">
{product.description || 'Koleksi fashion berkualitas'}
</p>
<p className="text-sm font-bold text-amber-600">
{getProductPriceRange(product)}
</p>
<div className="flex items-center gap-2 pt-1">
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
if (product.product_variants.length === 1) {
handleAddToCart();
} else {
onQuickView(product);
}
}}
className="w-9 h-9 rounded-full bg-amber-50 text-amber-600 hover:bg-amber-100 border-amber-200"
aria-label="Tambah ke Keranjang"
>
<ShoppingBag className="w-4 h-4" />
</Button>
<Button asChild className="flex-1 bg-amber-500 hover:bg-amber-600 text-white text-[11px] font-semibold uppercase tracking-wider py-2 rounded-lg transition-colors">
<a href={contactPhone ? waUrl : '#'} target="_blank" rel="noreferrer">
<ShoppingCart className="w-4 h-4" />
Beli Sekarang
</a>
</Button>
</div>
</div>
</div>
);

View File

@ -1,15 +1,26 @@
import { X, Star, Minus, Plus, Phone } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useCart } from '@/contexts/cart-context';
import { formatRupiah } from '@/lib/rupiah';
import type { Product, ProductVariant, ProductPrice } from '@/types/homepage';
import { formatWhatsAppPhone } from '@/lib/format';
import type { Product, ProductVariant } from '@/types/homepage';
import { Minus, Plus, ShoppingBag, ShoppingCart, X } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
type ProductModalProps = {
product: Product | null;
onClose: () => void;
contactPhone?: string | null;
};
function getProductImage(): string {
return 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80';
const FALLBACK_IMAGE = 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80';
function getVariantImage(variant: ProductVariant): string {
if (variant.photo_urls && variant.photo_urls.length > 0) {
return variant.photo_urls[0];
}
return FALLBACK_IMAGE;
}
function getRetailPrice(variant: ProductVariant): number {
@ -22,29 +33,46 @@ function getWholesalePrice(variant: ProductVariant): number {
return wholesale?.price ?? 0;
}
export default function ProductModal({ product, onClose }: ProductModalProps) {
export default function ProductModal({ product, onClose, contactPhone }: ProductModalProps) {
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
const [selectedPriceType, setSelectedPriceType] = useState<'retail' | 'wholesale'>('retail');
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [quantity, setQuantity] = useState(1);
const { addItem } = useCart();
if (!product) return null;
const variants = product.product_variants;
const activeVariant = selectedVariant ?? variants[0];
const variantImages: string[] = activeVariant?.photo_urls?.length
? activeVariant.photo_urls
: [FALLBACK_IMAGE];
const currentImage = selectedImage ?? variantImages[0];
const retailPrice = activeVariant ? getRetailPrice(activeVariant) : 0;
const wholesalePrice = activeVariant ? getWholesalePrice(activeVariant) : 0;
const currentPrice = selectedPriceType === 'retail' ? retailPrice : wholesalePrice;
const maxStock = activeVariant?.stock ?? 0;
const handleWhatsAppOrder = () => {
const handleVariantChange = (variant: ProductVariant) => {
setSelectedVariant(variant);
setSelectedImage(null);
setQuantity(1);
};
const handleAddToCart = () => {
if (!activeVariant) return;
const priceLabel = selectedPriceType === 'retail' ? 'Retail' : 'Grosir';
const message = `Halo, saya tertarik dengan produk:\n\n*${product.name}*\n- Varian: ${activeVariant.name}\n- Harga ${priceLabel}: Rp ${formatRupiah(currentPrice)}\n- Jumlah: ${quantity} pcs\n- Subtotal: Rp ${formatRupiah(currentPrice * quantity)}\n\nMohon info detail pesanan. Terima kasih!`;
const phone = '6281234567890';
const waUrl = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
window.open(waUrl, '_blank');
addItem({
productId: product.id,
productName: product.name,
variantId: activeVariant.id,
variantName: activeVariant.name,
priceType: selectedPriceType,
price: currentPrice,
imageUrl: getVariantImage(activeVariant),
quantity,
});
toast.success(`${product.name} ditambahkan ke keranjang`);
};
return (
@ -59,22 +87,43 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
{/* Header */}
<div className="flex justify-between items-center p-6 border-b border-amber-100/30">
<h3 className="text-lg font-serif text-slate-800">Detail Produk</h3>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-slate-100 transition-colors"
>
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full hover:bg-slate-100">
<X className="w-5 h-5 text-slate-500" />
</button>
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
{/* Image */}
<div className="relative aspect-3/4 rounded-2xl overflow-hidden">
<img
src={getProductImage()}
alt={product.name}
className="w-full h-full object-cover"
/>
<div className="space-y-3">
<div className="relative aspect-3/4 rounded-2xl overflow-hidden">
<img
key={currentImage}
src={currentImage}
alt={product.name}
className="w-full h-full object-cover"
/>
</div>
{variantImages.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{variantImages.map((url, i) => (
<button
key={i}
onClick={() => setSelectedImage(url)}
className={`relative shrink-0 w-16 h-20 rounded-lg overflow-hidden border-2 transition-all ${currentImage === url
? 'border-amber-500 ring-2 ring-amber-200'
: 'border-slate-200 hover:border-amber-300 opacity-70 hover:opacity-100'
}`}
>
<img
src={url}
alt={`${product.name} ${i + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
)}
</div>
{/* Details */}
@ -82,12 +131,9 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
<div>
<div className="flex items-center gap-2 mb-2">
{product.categories.slice(0, 2).map((cat) => (
<span
key={cat.id}
className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full"
>
<Badge key={cat.id} variant="secondary" className="bg-amber-50 text-amber-600 uppercase tracking-wider font-semibold px-2 py-0.5">
{cat.name}
</span>
</Badge>
))}
</div>
<h2 className="text-2xl font-serif text-slate-800">{product.name}</h2>
@ -97,29 +143,12 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
{product.description || 'Produk fashion berkualitas dengan desain elegan dan bahan nyaman.'}
</p>
{/* Rating */}
<div className="flex items-center gap-2">
<div className="flex text-amber-400">
<Star className="w-4 h-4 fill-current" />
<Star className="w-4 h-4 fill-current" />
<Star className="w-4 h-4 fill-current" />
<Star className="w-4 h-4 fill-current" />
<Star className="w-4 h-4 fill-current" />
</div>
<span className="text-xs text-slate-400">5.0 (120+)</span>
</div>
{/* Price */}
<div className="bg-amber-50/50 p-4 rounded-xl">
<p className="text-xs text-slate-500 mb-1">Harga</p>
<p className="text-2xl font-bold text-amber-600">
Rp {formatRupiah(currentPrice)}
</p>
{selectedPriceType === 'wholesale' && wholesalePrice > 0 && (
<p className="text-xs text-slate-400 mt-1">
Harga retail: Rp {formatRupiah(retailPrice)}
</p>
)}
</div>
{/* Variant Selection */}
@ -128,20 +157,21 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
<p className="text-xs font-semibold text-slate-700 mb-2">Varian</p>
<div className="flex flex-wrap gap-2">
{variants.map((variant) => (
<button
<Button
key={variant.id}
onClick={() => setSelectedVariant(variant)}
className={`px-4 py-2 text-xs font-semibold rounded-xl border transition-all ${
activeVariant?.id === variant.id
? 'bg-amber-600 text-white border-transparent'
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
}`}
variant={activeVariant?.id === variant.id ? 'default' : 'outline'}
size="sm"
onClick={() => handleVariantChange(variant)}
className={activeVariant?.id === variant.id
? 'bg-amber-600 text-white border-transparent'
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
}
>
{variant.name}
<span className="ml-1 text-[10px] opacity-70">
({variant.stock})
</span>
</button>
</Button>
))}
</div>
</div>
@ -151,26 +181,28 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
<div>
<p className="text-xs font-semibold text-slate-700 mb-2">Jenis Harga</p>
<div className="flex gap-2">
<button
<Button
variant={selectedPriceType === 'retail' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedPriceType('retail')}
className={`px-4 py-2 text-xs font-semibold rounded-xl border transition-all ${
selectedPriceType === 'retail'
? 'bg-amber-600 text-white border-transparent'
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
}`}
className={selectedPriceType === 'retail'
? 'bg-amber-600 text-white border-transparent'
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
}
>
Retail
</button>
<button
</Button>
<Button
variant={selectedPriceType === 'wholesale' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedPriceType('wholesale')}
className={`px-4 py-2 text-xs font-semibold rounded-xl border transition-all ${
selectedPriceType === 'wholesale'
? 'bg-amber-600 text-white border-transparent'
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
}`}
className={selectedPriceType === 'wholesale'
? 'bg-amber-600 text-white border-transparent'
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
}
>
Grosir
</button>
</Button>
</div>
</div>
@ -178,19 +210,21 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
<div>
<p className="text-xs font-semibold text-slate-700 mb-2">Jumlah</p>
<div className="flex items-center gap-3 border border-slate-200 rounded-xl px-3 py-2 w-fit">
<button
<Button
variant="ghost"
size="icon-xs"
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="p-1 text-slate-500 hover:text-slate-800"
>
<Minus className="w-4 h-4" />
</button>
</Button>
<span className="text-sm font-semibold w-8 text-center">{quantity}</span>
<button
<Button
variant="ghost"
size="icon-xs"
onClick={() => setQuantity(Math.min(maxStock, quantity + 1))}
className="p-1 text-slate-500 hover:text-slate-800"
>
<Plus className="w-4 h-4" />
</button>
</Button>
</div>
<p className="text-[10px] text-slate-400 mt-1">Stok tersedia: {maxStock}</p>
</div>
@ -205,14 +239,19 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
</div>
</div>
{/* WhatsApp Order */}
<button
onClick={handleWhatsAppOrder}
className="w-full flex items-center justify-center bg-emerald-600 text-white hover:bg-emerald-500 py-3.5 text-xs font-bold uppercase tracking-widest rounded-xl transition-all shadow-md"
>
<Phone className="w-4 h-4 mr-2" />
Pesan Via WhatsApp
</button>
{/* Actions */}
<div className="flex gap-3">
<Button onClick={handleAddToCart} variant="outline" className="flex-1 border-amber-200 text-amber-700 hover:bg-amber-50 py-3.5 text-xs font-bold uppercase tracking-widest rounded-xl transition-all">
<ShoppingBag className="w-4 h-4 mr-2" />
Keranjang
</Button>
<Button asChild className="flex-1 bg-amber-600 text-white hover:bg-amber-500 py-3.5 text-xs font-bold uppercase tracking-widest rounded-xl transition-all shadow-md">
<a href={contactPhone ? `https://wa.me/${formatWhatsAppPhone(contactPhone)}?text=${encodeURIComponent(`Halo min, saya mau order, berikut pesanannya.\n\n*${product.name}*\n* Varian: ${activeVariant?.name ?? '-'}\n* Harga: Rp ${formatRupiah(currentPrice)}\n* QTY : ${quantity}\n* Subtotal : Rp ${formatRupiah(currentPrice * quantity)}\n\nTotal: Rp ${formatRupiah(currentPrice * quantity)}\n\nMohon diinformasikan detail pembayaran dan proses selanjutnya ya, Kak.\n\nTerimakasih`)}` : '#'} target="_blank" rel="noreferrer">
<ShoppingCart className="w-4 h-4 mr-2" />
Beli Sekarang
</a>
</Button>
</div>
</div>
</div>
</div>

View File

@ -1,5 +1,3 @@
import { Link, usePage } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
import { AppLogo, AppLogoIcon } from '@/components/brand';
import { Breadcrumbs } from '@/components/layout';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@ -22,17 +20,14 @@ import {
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { UserMenuContent } from '@/components/user';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { useInitials } from '@/hooks/use-initials';
import { cn, toUrl } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { dashboard } from '@/routes';
import type { BreadcrumbItem, NavItem } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { LayoutGrid, Menu } from 'lucide-react';
type Props = {
breadcrumbs?: BreadcrumbItem[];
@ -46,19 +41,6 @@ const mainNavItems: NavItem[] = [
},
];
const rightNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/react-starter-kit',
icon: Folder,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#react',
icon: BookOpen,
},
];
const activeItemStyles =
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
@ -110,23 +92,6 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
</Link>
))}
</div>
<div className="flex flex-col space-y-4">
{rightNavItems.map((item) => (
<a
key={item.title}
href={toUrl(item.href)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 font-medium"
>
{item.icon && (
<item.icon className="h-5 w-5" />
)}
<span>{item.title}</span>
</a>
))}
</div>
</div>
</div>
</SheetContent>
@ -176,40 +141,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
</div>
<div className="ml-auto flex items-center space-x-2">
<div className="relative flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
className="group h-9 w-9 cursor-pointer"
>
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
</Button>
<div className="ml-1 hidden gap-1 lg:flex">
{rightNavItems.map((item) => (
<Tooltip key={item.title}>
<TooltipTrigger>
<a
href={toUrl(item.href)}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium text-accent-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
>
<span className="sr-only">
{item.title}
</span>
{item.icon && (
<item.icon className="size-5 opacity-80 group-hover:opacity-100" />
)}
</a>
</TooltipTrigger>
<TooltipContent>
<p>{item.title}</p>
</TooltipContent>
</Tooltip>
))}
</div>
</div>
<DropdownMenu>
<DropdownMenu>k
<DropdownMenuTrigger asChild>
<Button
variant="ghost"

View File

@ -0,0 +1,102 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
export type CartItem = {
productId: number;
productName: string;
variantId: number;
variantName: string;
priceType: 'retail' | 'wholesale';
price: number;
quantity: number;
imageUrl: string;
};
type CartContextType = {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'> & { quantity?: number }) => void;
removeItem: (variantId: number) => void;
updateQuantity: (variantId: number, quantity: number) => void;
clearCart: () => void;
totalItems: number;
totalPrice: number;
};
const CartContext = createContext<CartContextType | null>(null);
const STORAGE_KEY = 'dst_cart';
const VISITOR_KEY = 'dst_visitor_id';
function getVisitorId(): string {
let id = localStorage.getItem(VISITOR_KEY);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(VISITOR_KEY, id);
}
return id;
}
function loadCart(): CartItem[] {
try {
const raw = localStorage.getItem(`${STORAGE_KEY}_${getVisitorId()}`);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveCart(items: CartItem[]): void {
localStorage.setItem(`${STORAGE_KEY}_${getVisitorId()}`, JSON.stringify(items));
}
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>(loadCart);
useEffect(() => {
saveCart(items);
}, [items]);
const addItem = useCallback((item: Omit<CartItem, 'quantity'> & { quantity?: number }) => {
setItems((prev) => {
const existing = prev.find((i) => i.variantId === item.variantId);
if (existing) {
return prev.map((i) =>
i.variantId === item.variantId
? { ...i, quantity: i.quantity + (item.quantity ?? 1) }
: i,
);
}
return [...prev, { ...item, quantity: item.quantity ?? 1 }];
});
}, []);
const removeItem = useCallback((variantId: number) => {
setItems((prev) => prev.filter((i) => i.variantId !== variantId));
}, []);
const updateQuantity = useCallback((variantId: number, quantity: number) => {
if (quantity <= 0) {
setItems((prev) => prev.filter((i) => i.variantId !== variantId));
return;
}
setItems((prev) => prev.map((i) => (i.variantId === variantId ? { ...i, quantity } : i)));
}, []);
const clearCart = useCallback(() => {
setItems([]);
}, []);
const totalItems = useMemo(() => items.reduce((sum, i) => sum + i.quantity, 0), [items]);
const totalPrice = useMemo(() => items.reduce((sum, i) => sum + i.price * i.quantity, 0), [items]);
return (
<CartContext.Provider value={{ items, addItem, removeItem, updateQuantity, clearCart, totalItems, totalPrice }}>
{children}
</CartContext.Provider>
);
}
export function useCart(): CartContextType {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used within CartProvider');
return ctx;
}

View File

@ -5,6 +5,20 @@ export function formatNumber(
return new Intl.NumberFormat('id-ID', options).format(num);
}
export function formatWhatsAppPhone(phone: string): string {
const digits = phone.replace(/\D/g, '');
if (digits.startsWith('62')) {
return digits;
}
if (digits.startsWith('0')) {
return '62' + digits.slice(1);
}
return '62' + digits;
}
export type DateFormatStyle = 'full' | 'short' | 'datetime';
export function formatDate(dateString: string, style: DateFormatStyle = 'full'): string {

View File

@ -515,7 +515,7 @@ export default function Analysis({
}
if (hasAnyRole(['admin_toko', 'direktur'])) {
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topProducts: 11, topCustomers: 12, busyHours: 13 };
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topSuppliers: 11, topProducts: 12, topCustomers: 13, busyHours: 14 };
}
if (hasRole('marketing')) {

View File

@ -28,7 +28,7 @@ export type EmployeeAdvance = {
description: string;
due_date: string;
formatted_due_date: string;
status: 'pending' | 'approved' | 'paid' | 'rejected' | 'cancelled';
status: 'pending' | 'disbursed' | 'partial' | 'repaid' | 'rejected';
created_at: string;
formatted_created_at: string;
employee: {
@ -48,22 +48,22 @@ function getStatusBadge(status: string) {
label: 'Menunggu',
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100',
},
approved: {
label: 'Disetujui',
disbursed: {
label: 'Dikeluarkan',
className: 'bg-green-100 text-green-800 hover:bg-green-100',
},
paid: {
label: 'Dibayar',
partial: {
label: 'Cicilan',
className: 'bg-orange-100 text-orange-800 hover:bg-orange-100',
},
repaid: {
label: 'Lunas',
className: 'bg-blue-100 text-blue-800 hover:bg-blue-100',
},
rejected: {
label: 'Ditolak',
className: 'bg-red-100 text-red-800 hover:bg-red-100',
},
cancelled: {
label: 'Dibatalkan',
className: 'bg-gray-100 text-gray-800 hover:bg-gray-100',
},
};
const config = statusConfig[status] ?? statusConfig.pending;
@ -127,7 +127,7 @@ export function createEmployeeAdvanceColumns(
cell: ({ row }) => {
const employeeAdvance = row.original;
if (employeeAdvance.status === 'paid') {
if (employeeAdvance.status === 'repaid') {
return <span className="text-green-600">Lunas</span>;
}
@ -202,7 +202,7 @@ export function createEmployeeAdvanceColumns(
),
show:
can('employee_advances.pay') &&
employeeAdvance.status === 'approved',
(employeeAdvance.status === 'disbursed' || employeeAdvance.status === 'partial'),
onClick: () => handlePay(employeeAdvance),
},
{

View File

@ -32,6 +32,7 @@ export type Product = {
slug: string;
description: string | null;
status: string;
is_featured: boolean;
rejection_reason: string | null;
categories: {
id: number;

View File

@ -32,6 +32,7 @@ import {
edit as productEdit,
index as productIndex,
toggleStatus,
toggleFeatured as productToggleFeatured,
approve as productApprove,
reject as productReject,
resubmit as productResubmit,
@ -63,6 +64,7 @@ type Props = {
name?: string;
stock?: string;
category?: string;
featured?: string;
};
};
@ -160,7 +162,8 @@ export default function ProductIndex({ products, categories, productNames, filte
filters.status ||
filters.name ||
filters.stock ||
filters.category,
filters.category ||
filters.featured,
)}
onClear={clearFilters}
>
@ -265,6 +268,25 @@ export default function ProductIndex({ products, categories, productNames, filte
</ComboboxContent>
</Combobox>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Halaman Depan
</label>
<Select
value={filters.featured ?? 'all'}
onValueChange={(value) => applyFilter('featured', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="true">Ditampilkan</SelectItem>
<SelectItem value="false">Tidak Ditampilkan</SelectItem>
</SelectContent>
</Select>
</div>
</FilterPopover>
);
@ -321,6 +343,7 @@ export default function ProductIndex({ products, categories, productNames, filte
onDelete={(p) => setDeleting(p)}
onReject={(p) => setRejecting(p)}
toggleStatusUrl={(id) => toggleStatus.url(id)}
toggleFeaturedUrl={(id) => productToggleFeatured.url(id)}
approveUrl={(id) => productApprove.url(id)}
resubmitUrl={(id) => productResubmit.url(id)}
/>

View File

@ -42,6 +42,7 @@ export type ProductCardRowParams = {
onDelete: (product: Product) => void;
onReject: (product: Product) => void;
toggleStatusUrl: (id: number) => string;
toggleFeaturedUrl: (id: number) => string;
approveUrl: (id: number) => string;
resubmitUrl: (id: number) => string;
};
@ -55,6 +56,7 @@ export function ProductCardRow({
onDelete,
onReject,
toggleStatusUrl,
toggleFeaturedUrl,
approveUrl,
resubmitUrl,
}: ProductCardRowParams) {
@ -173,6 +175,16 @@ export function ProductCardRow({
</span>
)}
</div>
{(product.status === 'active' || product.status === 'inactive') && (
<div className="mt-2">
<ToggleStatus
url={toggleFeaturedUrl(product.id)}
checked={product.is_featured}
label={product.is_featured ? 'Ditampilkan di Halaman Depan' : 'Tidak Ditampilkan'}
/>
</div>
)}
</div>
{/* Actions */}

View File

@ -1,23 +1,28 @@
import { Head, Link, router, InfiniteScroll } from '@inertiajs/react';
import { useState, useCallback, useRef } from 'react';
import ProductCard from '@/components/home/ProductCard';
import CartDrawer from '@/components/home/CartDrawer';
import ProductModal from '@/components/home/ProductModal';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCart } from '@/contexts/cart-context';
import { formatRupiah } from '@/lib/rupiah';
import type { Category, HomepageData, Paginated, Product, ProductPrice } from '@/types/homepage';
import { Head, InfiniteScroll, Link, router } from '@inertiajs/react';
import {
ArrowRight,
Check,
ChevronRight,
Info,
LogIn,
Mail,
MapPin,
Phone,
Plus,
Search,
ShoppingBag,
Plus,
ArrowRight,
Star,
Info,
ChevronRight,
Phone,
MapPin,
Mail,
LogIn,
Check,
} from 'lucide-react';
import { formatRupiah } from '@/lib/rupiah';
import ProductCard from '@/components/home/ProductCard';
import ProductModal from '@/components/home/ProductModal';
import type { Product, Category, HomepageData, ProductPrice, Paginated } from '@/types/homepage';
import { useCallback, useEffect, useRef, useState } from 'react';
type Props = {
appName: string;
@ -52,30 +57,43 @@ export default function Welcome({
const [searchInput, setSearchInput] = useState(filters.search);
const [selectedCategory, setSelectedCategory] = useState(filters.category);
const [activeProduct, setActiveProduct] = useState<Product | null>(null);
const [activeSection, setActiveSection] = useState('hero');
const [cartOpen, setCartOpen] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const { totalItems } = useCart();
useEffect(() => {
const sections = ['hero', 'catalog', 'gallery', 'order-guide', 'about', 'contact'];
const observers: IntersectionObserver[] = [];
sections.forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setActiveSection(id);
}
},
{ rootMargin: '-40% 0px -55% 0px' },
);
observer.observe(el);
observers.push(observer);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
const FALLBACK_IMAGE = 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80';
const getProductImage = (product: Product): string => {
const fallbacks: Record<string, string[]> = {
daster: [
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
],
'setelan-celana': [
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
],
atasan: [
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
],
bawahan: [
'https://images.unsplash.com/photo-1583496661160-fb4886b36ca7?w=800&auto=format&fit=crop&q=80',
],
};
const firstCat = product.categories?.[0]?.slug || '';
const urls = fallbacks[firstCat] || [
'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80',
];
return urls[0];
for (const variant of product.product_variants) {
if (variant.photo_urls && variant.photo_urls.length > 0) {
return variant.photo_urls[0];
}
}
return FALLBACK_IMAGE;
};
const getProductPriceRange = (product: Product): string => {
@ -145,7 +163,6 @@ export default function Welcome({
fetchProducts('', '');
}, [fetchProducts]);
// Use first 3 products from current page as featured for hero
const featuredProducts = products.data.slice(0, 3);
return (
@ -159,30 +176,42 @@ export default function Welcome({
{/* Sticky Header */}
<header className="sticky top-0 z-40 bg-white/70 backdrop-blur-lg border-b border-amber-100/50 transition-all">
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
<div className="flex items-center space-x-3">
<Link href="/" className="flex items-center space-x-2">
<span className="font-serif text-2xl font-bold tracking-widest text-amber-600">
{appName}
</span>
</Link>
</div>
<Link href="/" className="flex items-center">
<img src="/assets/logo.png" alt={appName} className="h-12 w-auto" />
</Link>
<nav className="hidden lg:flex items-center space-x-8 text-xs font-semibold uppercase tracking-widest text-slate-600">
<a href="#" className="hover:text-amber-600 transition-colors">Beranda</a>
<a href="#catalog" className="hover:text-amber-600 transition-colors">Produk</a>
<a href="#about" className="hover:text-amber-600 transition-colors">Tentang Kami</a>
<a href="#order-guide" className="hover:text-amber-600 transition-colors">Cara Pesan</a>
<a href="#contact" className="hover:text-amber-600 transition-colors">Hubungi Kami</a>
{[
{ id: 'hero', label: 'Beranda' },
{ id: 'catalog', label: 'Produk' },
{ id: 'about', label: 'Tentang Kami' },
{ id: 'order-guide', label: 'Cara Pesan' },
{ id: 'contact', label: 'Hubungi Kami' },
].map((item) => (
<a
key={item.id}
href={`#${item.id}`}
className={`transition-colors ${activeSection === item.id ? 'text-amber-600' : 'hover:text-amber-600'}`}
>
{item.label}
</a>
))}
</nav>
<div className="flex items-center space-x-4">
<a
href="/admin/dashboard"
className="p-2 rounded-full hover:bg-amber-50 text-slate-600 transition-colors"
aria-label="Login"
>
<LogIn className="w-4.5 h-4.5 text-amber-600" />
</a>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => setCartOpen(true)} className="relative">
<ShoppingBag className="w-4.5 h-4.5 text-amber-600" />
{totalItems > 0 && (
<span className="absolute -top-1 -right-1 w-4.5 h-4.5 bg-amber-600 text-white text-[9px] font-bold rounded-full flex items-center justify-center">
{totalItems}
</span>
)}
</Button>
<Button variant="ghost" size="icon" asChild>
<a href="/admin/dashboard" aria-label="Login">
<LogIn className="w-4.5 h-4.5 text-amber-600" />
</a>
</Button>
</div>
</div>
</header>
@ -197,53 +226,49 @@ export default function Welcome({
<div className="max-w-7xl mx-auto px-6 w-full lg:grow grid grid-cols-1 lg:grid-cols-12 gap-8 items-center py-12 z-10">
{/* Left Column */}
<div className="lg:col-span-5 flex flex-col justify-center space-y-6 lg:pr-6">
<div className="inline-flex items-center space-x-2 bg-amber-500/10/10 text-[10px] text-amber-700 uppercase tracking-widest px-3 py-1 rounded-full w-fit font-bold shadow-sm">
<span>{homepage.hero_badge}</span>
</div>
<Badge variant="secondary" className="w-fit bg-amber-500/10 text-amber-700 uppercase tracking-widest font-bold px-3 py-1 shadow-sm">
Hadir dengan Gaya Terbaru
</Badge>
<h1 className="text-5xl md:text-6xl font-serif leading-[1.1] tracking-tight font-light select-none text-slate-800">
{homepage.hero_title_line1} <br />
{homepage.hero_title_line2}{' '}
Nyaman<br />
dalam setiap{' '}
<span className="font-normal italic text-amber-600 bg-amber-100/20 px-2 rounded-lg">
{homepage.hero_title_highlight}
Penampilan
</span>
</h1>
<p className="text-sm text-slate-500 max-w-md leading-relaxed font-light">
{homepage.hero_description}
Koleksi fashion wanita dengan sentuhan elegan, material berkualitas, dan kenyamanan yang dapat Anda rasakan di setiap pemakaian.
</p>
<div className="flex items-center space-x-4 pt-4">
<a
href="#catalog"
className="inline-flex items-center justify-center bg-amber-600 text-white px-8 py-3.5 text-xs font-semibold uppercase tracking-widest rounded-xl shadow-lg shadow-amber-200/50 hover:bg-amber-500 hover:-translate-y-0.5 transition-all duration-300 group"
>
{homepage.hero_cta_primary_text}
<ArrowRight className="w-3.5 h-3.5 ml-2 transform group-hover:translate-x-1 transition-transform" />
</a>
<a
href="#about"
className="inline-flex items-center justify-center border border-amber-200 text-xs font-semibold uppercase tracking-widest px-8 py-3.5 rounded-xl hover:bg-amber-500/5 hover:border-amber-400 transition-all"
>
{homepage.hero_cta_secondary_text}
</a>
<Button asChild className="bg-amber-600 text-white px-8 py-3.5 text-xs font-semibold uppercase tracking-widest rounded-xl shadow-lg shadow-amber-200/50 hover:bg-amber-500 hover:-translate-y-0.5 transition-all duration-300 group">
<a href="#catalog">
Beli Sekarang
<ArrowRight className="w-3.5 h-3.5 ml-2 transform group-hover:translate-x-1 transition-transform" />
</a>
</Button>
<Button variant="outline" asChild className="border-amber-200 text-xs font-semibold uppercase tracking-widest px-8 py-3.5 rounded-xl hover:bg-amber-500/5 hover:border-amber-400 transition-all">
<a href="#about">Tentang Kami</a>
</Button>
</div>
</div>
{/* Center Column: Visual */}
<div className="lg:col-span-7 relative flex justify-center items-center h-[500px] lg:h-[650px] w-full">
<div className="absolute inset-0 flex justify-center items-center pointer-events-none select-none z-0">
<div className="text-[14vw] lg:text-[120px] font-serif font-bold leading-none tracking-tight flex opacity-[0.03]">
<span className="text-slate-900">{homepage.hero_bg_text_left}</span>
<span className="text-amber-600">{homepage.hero_bg_text_right}</span>
</div>
</div>
<div className="absolute inset-0 -z-10 flex justify-center items-center pointer-events-none select-none">
<div className="absolute w-[350px] h-[350px] rounded-full bg-gradient-to-tr from-amber-400/25 via-yellow-400/20 to-orange-300/20 blur-3xl" />
<div className="absolute w-[400px] h-[400px] rounded-full bg-gradient-to-br from-rose-300/20 via-amber-200/20 to-orange-300/20 blur-3xl" />
</div>
<div className="absolute inset-0 flex justify-center items-center pointer-events-none select-none z-0">
<div className="text-[14vw] lg:text-[120px] font-serif font-bold leading-none tracking-tight flex text-amber-200">
<span className="text-amber-600">DST</span>
<span className="text-slate-900">Collection</span>
</div>
</div>
<div className="relative w-[310px] h-[440px] lg:w-[390px] lg:h-[540px] rounded-[2rem] overflow-hidden z-10 transition-transform duration-500 hover:scale-[1.02]">
<img
src={homepage.hero_image_url || 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80'}
@ -302,14 +327,20 @@ export default function Welcome({
{/* Scroll Indicator */}
<div className="max-w-7xl mx-auto px-6 w-full flex items-center justify-between pb-8 z-10 text-xs font-semibold text-slate-500">
<div className="flex items-center space-x-3">
<span className="text-amber-600">#{homepage.scroll_hashtag}</span>
<span className="text-amber-600">#DSTCollection</span>
<span className="w-1 h-1 rounded-full bg-amber-300" />
<span>{homepage.scroll_tagline}</span>
<span>Bahan Adem & Lembut</span>
</div>
<a href="#catalog" className="flex items-center space-x-2 hover:text-amber-600 transition-colors">
<Button
variant="ghost"
onClick={() => {
document.getElementById('catalog')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
className="flex items-center space-x-2 hover:text-amber-600 transition-colors cursor-pointer text-xs font-semibold uppercase tracking-widest text-slate-500"
>
<span>Lihat katalog lengkap</span>
<ChevronRight className="w-3.5 h-3.5 rotate-90 text-amber-600" />
</a>
</Button>
</div>
</section>
@ -317,47 +348,51 @@ export default function Welcome({
<section id="catalog" className="max-w-7xl mx-auto px-6 py-20 border-t border-amber-100/30">
<div className="space-y-6 mb-12">
<div className="text-center space-y-2">
<h2 className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.catalog_badge}</h2>
<h3 className="text-3xl font-serif text-slate-800">{homepage.catalog_title}</h3>
<p className="text-sm text-slate-500 max-w-lg mx-auto font-light">{homepage.catalog_description}</p>
<Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
Katalog Eksklusif
</Badge>
<h3 className="text-3xl font-serif text-slate-800">Koleksi Busana Pilihan</h3>
<p className="text-sm text-slate-500 max-w-lg mx-auto font-light">Gunakan kategori dan filter di bawah untuk menyesuaikan pencarian busana idaman Anda dengan mudah.</p>
</div>
{/* Filters */}
<div className="bg-white/80 backdrop-blur-md p-6 rounded-3xl shadow-sm border border-amber-100/30 flex flex-col md:flex-row items-center gap-4 justify-between">
<div className="relative w-full md:w-80">
<input
<Input
value={searchInput}
onChange={(e) => handleSearchChange(e.target.value)}
type="text"
placeholder={homepage.catalog_search_placeholder}
className="w-full bg-slate-50 border border-slate-200 text-xs px-4 py-3 pr-10 rounded-xl outline-none focus:border-amber-500 text-slate-700 transition-all"
placeholder="Cari produk..."
className="w-full bg-slate-50 border-slate-200 text-xs px-4 py-3 pr-10 rounded-xl focus:border-amber-500 text-slate-700 transition-all"
/>
<Search className="absolute right-3 top-3.5 w-4 h-4 text-slate-400" />
</div>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<button
<Button
variant={!selectedCategory ? 'default' : 'outline'}
size="sm"
onClick={() => handleCategoryChange(null)}
className={`shrink-0 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider rounded-xl border transition-all whitespace-nowrap ${
!selectedCategory
? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50'
: 'bg-transparent text-slate-600 border-slate-200 hover'
}`}
className={`shrink-0 text-[11px] font-semibold uppercase tracking-wider rounded-xl transition-all whitespace-nowrap ${!selectedCategory
? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50'
: 'bg-transparent text-slate-600 border-slate-200 hover'
}`}
>
Semua
</button>
</Button>
{categories.map((category) => (
<button
<Button
key={category.id}
variant={selectedCategory === category.slug ? 'default' : 'outline'}
size="sm"
onClick={() => handleCategoryChange(selectedCategory === category.slug ? null : category.slug)}
className={`shrink-0 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider rounded-xl border transition-all whitespace-nowrap ${
selectedCategory === category.slug
? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50'
: 'bg-transparent text-slate-600 border-slate-200 hover'
}`}
className={`shrink-0 text-[11px] font-semibold uppercase tracking-wider rounded-xl transition-all whitespace-nowrap ${selectedCategory === category.slug
? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50'
: 'bg-transparent text-slate-600 border-slate-200 hover'
}`}
>
{category.name}
</button>
</Button>
))}
</div>
</div>
@ -384,6 +419,7 @@ export default function Welcome({
key={product.id}
product={product}
onQuickView={setActiveProduct}
contactPhone={contactPhone}
/>
))}
</div>
@ -394,12 +430,9 @@ export default function Welcome({
<p className="text-sm text-slate-400">
Mohon maaf, kami tidak menemukan pakaian yang cocok dengan kata kunci pencarian Anda.
</p>
<button
onClick={handleResetFilters}
className="mt-2 text-xs font-bold uppercase tracking-widest text-amber-600 underline underline-offset-4"
>
<Button variant="ghost" onClick={handleResetFilters} className="mt-2 text-xs font-bold uppercase tracking-widest text-amber-600 underline underline-offset-4">
Reset Pencarian
</button>
</Button>
</div>
)}
</InfiniteScroll>
@ -409,9 +442,11 @@ export default function Welcome({
<section id="gallery" className="w-full bg-gradient-to-br from-amber-50/40 via-yellow-50/20 to-orange-50/30 py-24 border-t border-b border-amber-100/30">
<div className="max-w-7xl mx-auto px-6 space-y-12">
<div className="text-center space-y-3 max-w-lg mx-auto">
<span className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.gallery_badge}</span>
<h3 className="text-4xl font-serif leading-tight">{homepage.gallery_title}</h3>
<p className="text-sm text-slate-500 leading-relaxed font-light">{homepage.gallery_description}</p>
<Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
Galeri Kami
</Badge>
<h3 className="text-4xl font-serif leading-tight">Koleksi Lookbook</h3>
<p className="text-sm text-slate-500 leading-relaxed font-light">Intip koleksi lookbook kami untuk inspirasi gaya sehari-hari. Padu padan pakaian modern yang nyaman untuk berbagai suasana.</p>
</div>
{homepage.gallery_images.length > 0 ? (
@ -441,13 +476,20 @@ export default function Welcome({
{/* Order Guide Section */}
<section id="order-guide" className="max-w-7xl mx-auto px-6 py-20 border-b border-amber-100/30">
<div className="text-center space-y-3 mb-16">
<h3 className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.order_guide_badge}</h3>
<h4 className="text-3xl font-serif">{homepage.order_guide_title}</h4>
<p className="text-sm text-slate-500 max-w-md mx-auto font-light">{homepage.order_guide_description}</p>
<Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
Langkah Pemesanan
</Badge>
<h4 className="text-3xl font-serif">Cara Melakukan Pemesanan</h4>
<p className="text-sm text-slate-500 max-w-md mx-auto font-light">Sistem pemesanan kami sangat mudah dan terhubung langsung via WhatsApp untuk pelayanan cepat dan personal.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{homepage.order_steps.map((step, index) => (
{[
{ title: 'Pilih Produk', description: 'Jelajahi pakaian favorit Anda, lalu ketuk "Lihat Detail" untuk memeriksa varian.' },
{ title: 'Pilih Varian & Harga', description: 'Tentukan varian yang diinginkan dan pilih jenis harga (Eceran, Grosir, Agen, dll.).' },
{ title: 'Masukkan Keranjang', description: 'Masukkan ke Keranjang Belanja untuk menampung seluruh daftar pakaian yang ingin Anda beli.' },
{ title: 'Kirim ke WhatsApp', description: 'Klik tombol kirim pesanan, admin kami akan merespons rincian transfer bank dan pengiriman kurir.' },
].map((step, index) => (
<div
key={index}
className="bg-white/50 p-6 rounded-2xl border border-amber-100/30 shadow-sm relative overflow-hidden text-center group hover:-translate-y-1 transition-transform"
@ -479,12 +521,14 @@ export default function Welcome({
/>
</div>
<div className="lg:col-span-6 space-y-6">
<span className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.about_badge}</span>
<h3 className="text-3xl font-serif">{homepage.about_title}</h3>
<Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
Tentang Kami
</Badge>
<h3 className="text-3xl font-serif">DST Collection</h3>
<p className="text-sm text-slate-500 leading-relaxed font-light whitespace-pre-line">
{aboutApp || 'Kami adalah rumah produksi busana dan batik lokal berkualitas tinggi. Berfokus pada keindahan motif, ketepatan detail jahitan, dan pemilihan kain adem yang mengedepankan aspek fungsionalitas dan estetika.'}
{aboutApp || 'Kami adalah rumah produksi busana lokal berkualitas tinggi. Berfokus pada keindahan motif, ketepatan detail jahitan, dan pemilihan kain adem yang mengedepankan aspek fungsionalitas dan estetika.'}
</p>
{homepage.about_features.map((feature, index) => (
{['Kain Rayon Super Tebal & Menyerap Keringat', 'Motif Eksklusif & Tidak Pasaran', 'Dukungan Penuh Layanan Admin Via WhatsApp'].map((feature, index) => (
<div key={index} className="flex items-center space-x-3 text-xs text-slate-500 font-semibold">
<Check className="w-4 h-4 text-emerald-500" />
<span>{feature}</span>
@ -496,13 +540,15 @@ export default function Welcome({
{/* Contact Section */}
<section id="contact" className="max-w-7xl mx-auto px-6 py-20">
<div className="bg-white/60 backdrop-blur-md border border-amber-100/30 p-8 rounded-3xl grid grid-cols-1 lg:grid-cols-2 gap-12 items-center shadow-sm">
<div className="space-y-6">
<span className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.contact_badge}</span>
<h3 className="text-3xl font-serif">{homepage.contact_title}</h3>
<p className="text-sm text-slate-500 leading-relaxed font-light">{homepage.contact_description}</p>
<div className="bg-white/60 backdrop-blur-md border border-amber-100/30 rounded-3xl overflow-hidden shadow-sm grid grid-cols-1 lg:grid-cols-2">
<div className="p-8 space-y-6 flex flex-col justify-center">
<Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1 w-fit">
Kontak Kami
</Badge>
<h3 className="text-3xl font-serif">Ada Pertanyaan? Hubungi Kami</h3>
<p className="text-sm text-slate-500 leading-relaxed font-light">Kami sangat senang mendengarkan pertanyaan Anda terkait spesifikasi produk, ketersediaan grosir, atau kemitraan. Hubungi tim admin kami melalui media di bawah.</p>
<div className="space-y-4">
<div className="space-y-3">
{contactPhone && (
<div className="flex items-center space-x-3 text-sm">
<div className="w-8 h-8 rounded-lg bg-amber-100/40 text-amber-600 flex items-center justify-center">
@ -529,30 +575,17 @@ export default function Welcome({
)}
</div>
</div>
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 space-y-4">
<h4 className="font-bold text-base">{homepage.contact_form_title}</h4>
<div className="space-y-3">
<input
type="text"
placeholder="Nama Anda"
className="w-full text-xs px-4 py-3 bg-white border border-slate-200 rounded-lg outline-none focus:border-amber-500 text-slate-700"
/>
<textarea
placeholder="Tulis pesan Anda di sini..."
rows={4}
className="w-full text-xs px-4 py-3 bg-white border border-slate-200 rounded-lg outline-none focus:border-amber-500 text-slate-700"
/>
<a
href={`https://wa.me/${(contactPhone || '6281234567890').replace(/[^0-9]/g, '')}`}
target="_blank"
rel="noreferrer"
className="w-full inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-500 text-white py-3 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors"
>
<Phone className="w-4 h-4 mr-2" />
Kirim Melalui WhatsApp
</a>
</div>
<div className="h-80 lg:h-auto">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d304.913429094586!2d107.58352379265978!3d-6.414270414938328!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x2e696bd874fdab6d%3A0x12de5546c3d8eac4!2sAGEN%20JNE%20DEESTEPABUARAN!5e1!3m2!1sen!2sid!4v1786611903455!5m2!1sen!2sid"
width="100%"
height="100%"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="w-full h-full min-h-[320px]"
/>
</div>
</div>
</section>
@ -561,13 +594,9 @@ export default function Welcome({
<footer className="bg-white text-slate-600 py-16 border-t border-amber-100/30">
<div className="max-w-7xl mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="space-y-4">
<div className="flex items-center space-x-2">
<span className="font-serif text-xl font-bold tracking-widest text-amber-600">
{appName}
</span>
</div>
<img src="/assets/logo.png" alt={appName} className="h-10 w-auto" />
<p className="text-xs text-slate-400 leading-relaxed font-light">
{homepage.footer_description}
Galeri resmi {appName}. Pilihan busana lokal premium berpotongan modern dengan kenyamanan menyejukkan.
</p>
</div>
@ -610,12 +639,13 @@ export default function Welcome({
</div>
<div className="max-w-7xl mx-auto px-6 mt-12 pt-8 border-t border-amber-100/30 flex flex-col sm:flex-row justify-between items-center text-[10px] text-slate-400 font-light space-y-4 sm:space-y-0">
<p>&copy; {new Date().getFullYear()} {appName}. {homepage.footer_copyright}</p>
<p>&copy; {new Date().getFullYear()} {appName}. Hak Cipta Dilindungi.</p>
</div>
</footer>
{/* Product Modal */}
<ProductModal product={activeProduct} onClose={() => setActiveProduct(null)} />
<ProductModal product={activeProduct} onClose={() => setActiveProduct(null)} contactPhone={contactPhone} />
<CartDrawer open={cartOpen} onClose={() => setCartOpen(false)} contactPhone={contactPhone} />
</div>
</>
);

View File

@ -14,6 +14,7 @@ export type ProductVariant = {
stock: number;
formatted_stock: string;
product_prices: ProductPrice[];
photo_urls: string[];
};
export type ProductCategory = {
@ -42,40 +43,9 @@ export type Category = {
};
export type HomepageData = {
hero_badge: string;
hero_title_line1: string;
hero_title_line2: string;
hero_title_highlight: string;
hero_description: string;
hero_cta_primary_text: string;
hero_cta_secondary_text: string;
hero_image_url: string | null;
hero_bg_text_left: string;
hero_bg_text_right: string;
scroll_hashtag: string;
scroll_tagline: string;
catalog_badge: string;
catalog_title: string;
catalog_description: string;
catalog_search_placeholder: string;
gallery_badge: string;
gallery_title: string;
gallery_description: string;
gallery_images: string[];
order_guide_badge: string;
order_guide_title: string;
order_guide_description: string;
order_steps: Array<{ title: string; description: string }>;
about_badge: string;
about_title: string;
about_image_url: string | null;
about_features: string[];
contact_badge: string;
contact_title: string;
contact_description: string;
contact_form_title: string;
footer_description: string;
footer_copyright: string;
gallery_images: string[];
};
export type Paginated<T> = {

View File

@ -41,6 +41,7 @@
Route::resource('products', ProductController::class)->except(['show'])->middleware('permission:products.view|products.create|products.update|products.delete');
Route::post('products/{product}/toggle-status', [ProductController::class, 'toggleStatus'])->name('products.toggle-status')->middleware('permission:products.toggle_status');
Route::post('products/{product}/toggle-featured', [ProductController::class, 'toggleFeatured'])->name('products.toggle-featured')->middleware('permission:products.toggle_featured');
Route::post('products/{product}/approve', [ProductController::class, 'approve'])->name('products.approve')->middleware('permission:products.update');
Route::post('products/{product}/reject', [ProductController::class, 'reject'])->name('products.reject')->middleware('permission:products.update');
Route::post('products/{product}/resubmit', [ProductController::class, 'resubmit'])->name('products.resubmit')->middleware('permission:products.update');

View File

@ -522,7 +522,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($kasbonUser, WebPushNotification::class);
expect($kasbonUser->notifications()->where('title', 'Kasbon Disetujui')->count())->toBe(1);
expect($kasbonUser->notifications()->where('title', 'Kasbon Dikeluarkan')->count())->toBe(1);
});
test('EmployeeAdvanceService pay sends notification including the employee', function () {
@ -555,7 +555,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($kasbonUser, WebPushNotification::class);
expect($kasbonUser->notifications()->where('title', 'Kasbon Dibayar')->count())->toBe(1);
expect($kasbonUser->notifications()->where('title', 'Kasbon Dikembalikan')->count())->toBe(1);
});
/*