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) - Relations: category(BelongsTo→Category), product(BelongsTo→Product)
### `products` → 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` `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) - Casts: status(ProductStatus), is_featured(bool)
- Scopes: active(), draft(), inactive(), pending(), rejected() - Scopes: active(), draft(), featured(), inactive(), pending(), rejected()
- Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant) - Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant)
### `product_variants` → ProductVariant ### `product_variants` → ProductVariant
@ -288,7 +288,7 @@ ## Enums
|------|--------|---------| |------|--------|---------|
| `CashTransactionType` | deposit, expense, transfer, withdrawal | cash_transactions.type | | `CashTransactionType` | deposit, expense, transfer, withdrawal | cash_transactions.type |
| `CuttingStatus` | in_progress, completed, cancelled | cuttings.status | | `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 | | `EmploymentStatus` | full_time, part_time, contract, internship, resigned | employees.employment_status |
| `Gender` | male, female | user_profiles.gender | | `Gender` | male, female | user_profiles.gender |
| `LeaveRequestStatus` | pending, approved, rejected, cancelled | leave_requests.status | | `LeaveRequestStatus` | pending, approved, rejected, cancelled | leave_requests.status |
@ -336,6 +336,7 @@ ### Master
| `RawMaterialVariantRequest` | `variant` | `required, string, max:200` | ✅ varchar(200) | | `RawMaterialVariantRequest` | `variant` | `required, string, max:200` | ✅ varchar(200) |
| `RawMaterialVariantRequest` | `price` | `required, integer, min:0` | ✅ uint | | `RawMaterialVariantRequest` | `price` | `required, integer, min:0` | ✅ uint |
| `ProductRequest` | `name` | `required, string, max:200` | ✅ varchar(200) | | `ProductRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
| `ProductRequest` | `is_featured` | `nullable, boolean` | ✅ bool |
| `ProductVariantRequest` | `name` | `required, string, max:200` | ✅ varchar(200) | | `ProductVariantRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
### Finance ### Finance

View File

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

View File

@ -71,6 +71,7 @@ enum Permission: string
case PRODUCTS_UPDATE = 'products.update'; case PRODUCTS_UPDATE = 'products.update';
case PRODUCTS_DELETE = 'products.delete'; case PRODUCTS_DELETE = 'products.delete';
case PRODUCTS_TOGGLE_STATUS = 'products.toggle_status'; case PRODUCTS_TOGGLE_STATUS = 'products.toggle_status';
case PRODUCTS_TOGGLE_FEATURED = 'products.toggle_featured';
case PRODUCTS_TRANSFER_STOCK = 'products.transfer_stock'; case PRODUCTS_TRANSFER_STOCK = 'products.transfer_stock';
case PRODUCTS_VIEW_STOCK_MUTATIONS = 'products.view_stock_mutations'; 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', [ return Inertia::render('admin/master/product/index', [
'products' => $this->service->paginated( 'products' => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
filters: $request->only(['status', 'stock', 'category', 'name']), filters: $request->only(['status', 'stock', 'category', 'name', 'featured']),
), ),
'categories' => $this->categoryService->getAll(), 'categories' => $this->categoryService->getAll(),
'productNames' => $this->service->getNames(), '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(); 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 public function approve(Product $product): RedirectResponse
{ {
$this->service->approve($product); $this->service->approve($product);

View File

@ -33,10 +33,12 @@ public function __invoke(Request $request): Response
$productsQuery = Product::select(['id', 'name', 'slug', 'description', 'status']) $productsQuery = Product::select(['id', 'name', 'slug', 'description', 'status'])
->active() ->active()
->featured()
->with([ ->with([
'categories:id,name,slug', 'categories:id,name,slug',
'productVariants:id,product_id,name,stock', 'productVariants:id,product_id,name,stock',
'productVariants.productPrices:id,variant_id,type,price', 'productVariants.productPrices:id,variant_id,type,price',
'productVariants.media',
]); ]);
if ($search !== '') { if ($search !== '') {
@ -52,9 +54,23 @@ public function __invoke(Request $request): Response
}); });
} }
$products = Inertia::scroll( $s3Service = $this->s3Service;
fn () => $productsQuery->orderBy('created_at', 'desc')->paginate(12) $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( $galleryImages = array_map(
fn ($key) => str_starts_with($key, 'http') ? $key : $this->s3Service->getTemporaryUrl($key, 60), 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, 'facebookUrl' => $socialMedia->facebook_url,
'tiktokUrl' => $socialMedia->tiktok_url, 'tiktokUrl' => $socialMedia->tiktok_url,
'homepage' => [ '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 '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)) ? (str_starts_with($homepage->hero_image_url, 'http') ? $homepage->hero_image_url : $this->s3Service->getTemporaryUrl($homepage->hero_image_url, 60))
: null, : 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 '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)) ? (str_starts_with($homepage->about_image_url, 'http') ? $homepage->about_image_url : $this->s3Service->getTemporaryUrl($homepage->about_image_url, 60))
: null, : null,
'about_features' => $homepage->about_features, 'gallery_images' => $galleryImages,
'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,
], ],
'categories' => $categories, 'categories' => $categories,
'products' => $products, 'products' => $products,
@ -117,8 +102,8 @@ public function __invoke(Request $request): Response
'category' => $category, 'category' => $category,
], ],
'seo' => [ 'seo' => [
'title' => $system->app_name.' - '.$homepage->hero_badge, 'title' => $system->app_name.' - Koleksi Segar 2026',
'description' => $homepage->hero_description, 'description' => 'Selamat datang di '.$system->app_name.'. Temukan keanggunan motif batik modern dan setelan pakaian santai berkualitas premium.',
'image' => url('/assets/logo.png'), 'image' => url('/assets/logo.png'),
'url' => url('/'), 'url' => url('/'),
], ],

View File

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

View File

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

View File

@ -26,6 +26,7 @@ protected function casts(): array
{ {
return [ return [
'status' => ProductStatus::class, 'status' => ProductStatus::class,
'is_featured' => 'boolean',
]; ];
} }
@ -55,6 +56,12 @@ protected function draft(Builder $query): void
$query->where('status', ProductStatus::DRAFT); $query->where('status', ProductStatus::DRAFT);
} }
#[Scope]
protected function featured(Builder $query): void
{
$query->where('is_featured', true);
}
#[Scope] #[Scope]
protected function inactive(Builder $query): void 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 public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance
{ {
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) { if ($employeeAdvance->status === EmployeeAdvanceStatus::REPAID) {
throw ValidationException::withMessages([ 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; $oldAmount = $employeeAdvance->amount;
$newAmount = $data['amount']; $newAmount = $data['amount'];
@ -113,7 +113,7 @@ public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeA
public function destroy(EmployeeAdvance $employeeAdvance): bool public function destroy(EmployeeAdvance $employeeAdvance): bool
{ {
return DB::transaction(function () use ($employeeAdvance) { 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( $this->creditCash(
$employeeAdvance->amount, $employeeAdvance->amount,
'Pembatalan kasbon: '.$employeeAdvance->description, 'Pembatalan kasbon: '.$employeeAdvance->description,
@ -122,7 +122,7 @@ public function destroy(EmployeeAdvance $employeeAdvance): bool
$employeeAdvance->cashTransaction()->delete(); $employeeAdvance->cashTransaction()->delete();
} }
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) { if ($employeeAdvance->status === EmployeeAdvanceStatus::REPAID || $employeeAdvance->status === EmployeeAdvanceStatus::PARTIAL) {
$this->creditCash( $this->creditCash(
$employeeAdvance->amount, $employeeAdvance->amount,
'Pembatalan kasbon: '.$employeeAdvance->description, 'Pembatalan kasbon: '.$employeeAdvance->description,
@ -159,7 +159,7 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
$employeeAdvance->update([ $employeeAdvance->update([
'cash_transaction_id' => $cashTransaction->id, 'cash_transaction_id' => $cashTransaction->id,
'status' => EmployeeAdvanceStatus::APPROVED, 'status' => EmployeeAdvanceStatus::DISBURSED,
'verified_by_id' => auth()->id(), 'verified_by_id' => auth()->id(),
'verified_at' => now(), 'verified_at' => now(),
]); ]);
@ -169,8 +169,8 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Kasbon Disetujui', title: 'Kasbon Dikeluarkan',
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.', 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'), url: route('admin.finance.employee-advances.index'),
additionalUser: $employeeAdvance->employee->user ?? null, additionalUser: $employeeAdvance->employee->user ?? null,
); );
@ -208,7 +208,7 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva
$employeeAdvance->update([ $employeeAdvance->update([
'paid_amount' => $newPaidAmount, '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_by_id' => $isFullyPaid ? auth()->id() : $employeeAdvance->paid_by_id,
'paid_at' => $isFullyPaid ? now() : $employeeAdvance->paid_at, 'paid_at' => $isFullyPaid ? now() : $employeeAdvance->paid_at,
]); ]);
@ -216,13 +216,13 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva
return $employeeAdvance; return $employeeAdvance;
}); });
$notificationBody = $employeeAdvance->status === EmployeeAdvanceStatus::PAID $notificationBody = $employeeAdvance->status === EmployeeAdvanceStatus::REPAID
? 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.' ? '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, ',', '.').'.'; : '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( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], 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, body: $notificationBody,
url: route('admin.finance.employee-advances.index'), url: route('admin.finance.employee-advances.index'),
additionalUser: $employeeAdvance->employee->user ?? null, 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 public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = Product::query() $paginator = Product::query()
->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason']) ->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason', 'is_featured'])
->with([ ->with([
'categories:id,name', 'categories:id,name',
'productVariants:id,product_id,name,stock,reject_stock,retail_stock', '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) { ->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $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) { ->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'); $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 public function approve(Product $product): void
{ {
$product->update([ $product->update([

View File

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

View File

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

View File

@ -6,73 +6,11 @@
class HomepageSettings extends Settings 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_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 ?string $about_image_url;
public array $about_features; public array $gallery_images;
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 static function group(): string public static function group(): string
{ {

View File

@ -17,7 +17,7 @@ public function definition(): array
'paid_amount' => 0, 'paid_amount' => 0,
'description' => fake()->sentence(), 'description' => fake()->sentence(),
'due_date' => fake()->date(), '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->string('slug', 200)->unique();
$table->text('description')->nullable(); $table->text('description')->nullable();
$table->string('status', 20)->default(ProductStatus::ACTIVE->value); $table->string('status', 20)->default(ProductStatus::ACTIVE->value);
$table->boolean('is_featured')->default(false);
$table->timestamp('created_at')->useCurrent(); $table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();

View File

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

View File

@ -8,82 +8,16 @@
public function up(): void public function up(): void
{ {
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): 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_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('about_image_url', 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80');
$blueprint->add('hero_bg_text_right', 'Collection'); $blueprint->add('gallery_images', [
// 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', [
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80', '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-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-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-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 public function up(): void
{ {
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void { $this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void {
// Delete old deal fields
$blueprint->delete('deal_badge'); $blueprint->delete('deal_badge');
$blueprint->delete('deal_title'); $blueprint->delete('deal_title');
$blueprint->delete('deal_description'); $blueprint->delete('deal_description');
$blueprint->delete('deal_cta_text'); $blueprint->delete('deal_cta_text');
$blueprint->delete('deal_images'); $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 { FlashToast, PWAUpdateToast } from '@/components/notifications';
import { registerSW } from 'virtual:pwa-register';
import { FlashToast } from '@/components/notifications';
import { PWAUpdateToast } from '@/components/notifications';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
import { CartProvider } from '@/contexts/cart-context';
import { initializeTheme } from '@/hooks/use-appearance'; import { initializeTheme } from '@/hooks/use-appearance';
import AppLayout from '@/layouts/app-layout'; import AppLayout from '@/layouts/app-layout';
import AuthLayout from '@/layouts/auth-layout'; import AuthLayout from '@/layouts/auth-layout';
import SettingsLayout from '@/layouts/settings/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({ registerSW({
onNeedRefresh() { onNeedRefresh() {
@ -26,6 +26,8 @@ createInertiaApp({
switch (true) { switch (true) {
case name === 'admin/manage/cutting/show': case name === 'admin/manage/cutting/show':
return null; return null;
case name === 'welcome':
return null;
case name.startsWith('auth/'): case name.startsWith('auth/'):
return AuthLayout; return AuthLayout;
case name.startsWith('settings/'): case name.startsWith('settings/'):
@ -37,12 +39,14 @@ createInertiaApp({
strictMode: true, strictMode: true,
withApp(app) { withApp(app) {
return ( return (
<TooltipProvider delayDuration={0}> <CartProvider>
<FlashToast /> <TooltipProvider delayDuration={0}>
<PWAUpdateToast /> <FlashToast />
{app} <PWAUpdateToast />
<Toaster /> {app}
</TooltipProvider> <Toaster />
</TooltipProvider>
</CartProvider>
); );
}, },
progress: { 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 { formatRupiah } from '@/lib/rupiah';
import { formatWhatsAppPhone } from '@/lib/format';
import type { Product } from '@/types/homepage'; import type { Product } from '@/types/homepage';
import { ShoppingBag, ShoppingCart } from 'lucide-react';
import { toast } from 'sonner';
type ProductCardProps = { type ProductCardProps = {
product: Product; product: Product;
onQuickView: (product: Product) => void; 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 { function getProductImage(product: Product): string {
for (const variant of product.product_variants) { for (const variant of product.product_variants) {
const prices = variant.product_prices; if (variant.photo_urls && variant.photo_urls.length > 0) {
if (prices.length > 0) { return variant.photo_urls[0];
return `https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80`;
} }
} }
return FALLBACK_IMAGE;
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];
} }
function getProductPriceRange(product: Product): string { function getProductPriceRange(product: Product): string {
@ -71,46 +56,87 @@ function getProductPriceRange(product: Product): string {
: `Rp ${formatRupiah(min)} - Rp ${formatRupiah(max)}`; : `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 ( return (
<div <div className="cursor-pointer" onClick={() => onQuickView(product)}>
className="group 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"> <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 <img
src={getProductImage(product)} src={getProductImage(product)}
alt={product.name} 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>
<div className="mt-4 space-y-1"> <div className="mt-4 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{product.categories.slice(0, 2).map((cat) => ( {product.categories.slice(0, 2).map((cat) => (
<span <Badge key={cat.id} variant="secondary" className="bg-amber-50 text-amber-600 uppercase tracking-wider font-semibold px-2 py-0.5">
key={cat.id}
className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full"
>
{cat.name} {cat.name}
</span> </Badge>
))} ))}
</div> </div>
<h4 className="text-sm font-semibold text-slate-800 line-clamp-1"> <h4 className="text-sm font-semibold text-slate-800 line-clamp-1">
{product.name} {product.name}
</h4> </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"> <p className="text-sm font-bold text-amber-600">
{getProductPriceRange(product)} {getProductPriceRange(product)}
</p> </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>
</div> </div>
); );

View File

@ -1,15 +1,26 @@
import { X, Star, Minus, Plus, Phone } from 'lucide-react'; import { Badge } from '@/components/ui/badge';
import { useState } from 'react'; import { Button } from '@/components/ui/button';
import { useCart } from '@/contexts/cart-context';
import { formatRupiah } from '@/lib/rupiah'; 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 = { type ProductModalProps = {
product: Product | null; product: Product | null;
onClose: () => void; onClose: () => void;
contactPhone?: string | null;
}; };
function getProductImage(): string { const FALLBACK_IMAGE = 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80';
return '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 { function getRetailPrice(variant: ProductVariant): number {
@ -22,29 +33,46 @@ function getWholesalePrice(variant: ProductVariant): number {
return wholesale?.price ?? 0; 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 [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
const [selectedPriceType, setSelectedPriceType] = useState<'retail' | 'wholesale'>('retail'); const [selectedPriceType, setSelectedPriceType] = useState<'retail' | 'wholesale'>('retail');
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const { addItem } = useCart();
if (!product) return null; if (!product) return null;
const variants = product.product_variants; const variants = product.product_variants;
const activeVariant = selectedVariant ?? variants[0]; 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 retailPrice = activeVariant ? getRetailPrice(activeVariant) : 0;
const wholesalePrice = activeVariant ? getWholesalePrice(activeVariant) : 0; const wholesalePrice = activeVariant ? getWholesalePrice(activeVariant) : 0;
const currentPrice = selectedPriceType === 'retail' ? retailPrice : wholesalePrice; const currentPrice = selectedPriceType === 'retail' ? retailPrice : wholesalePrice;
const maxStock = activeVariant?.stock ?? 0; const maxStock = activeVariant?.stock ?? 0;
const handleWhatsAppOrder = () => { const handleVariantChange = (variant: ProductVariant) => {
setSelectedVariant(variant);
setSelectedImage(null);
setQuantity(1);
};
const handleAddToCart = () => {
if (!activeVariant) return; if (!activeVariant) return;
const priceLabel = selectedPriceType === 'retail' ? 'Retail' : 'Grosir'; addItem({
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!`; productId: product.id,
productName: product.name,
const phone = '6281234567890'; variantId: activeVariant.id,
const waUrl = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`; variantName: activeVariant.name,
window.open(waUrl, '_blank'); priceType: selectedPriceType,
price: currentPrice,
imageUrl: getVariantImage(activeVariant),
quantity,
});
toast.success(`${product.name} ditambahkan ke keranjang`);
}; };
return ( return (
@ -59,22 +87,43 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
{/* Header */} {/* Header */}
<div className="flex justify-between items-center p-6 border-b border-amber-100/30"> <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> <h3 className="text-lg font-serif text-slate-800">Detail Produk</h3>
<button <Button variant="ghost" size="icon" onClick={onClose} className="rounded-full hover:bg-slate-100">
onClick={onClose}
className="p-2 rounded-full hover:bg-slate-100 transition-colors"
>
<X className="w-5 h-5 text-slate-500" /> <X className="w-5 h-5 text-slate-500" />
</button> </Button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
{/* Image */} {/* Image */}
<div className="relative aspect-3/4 rounded-2xl overflow-hidden"> <div className="space-y-3">
<img <div className="relative aspect-3/4 rounded-2xl overflow-hidden">
src={getProductImage()} <img
alt={product.name} key={currentImage}
className="w-full h-full object-cover" 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> </div>
{/* Details */} {/* Details */}
@ -82,12 +131,9 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
<div> <div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
{product.categories.slice(0, 2).map((cat) => ( {product.categories.slice(0, 2).map((cat) => (
<span <Badge key={cat.id} variant="secondary" className="bg-amber-50 text-amber-600 uppercase tracking-wider font-semibold px-2 py-0.5">
key={cat.id}
className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full"
>
{cat.name} {cat.name}
</span> </Badge>
))} ))}
</div> </div>
<h2 className="text-2xl font-serif text-slate-800">{product.name}</h2> <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.'} {product.description || 'Produk fashion berkualitas dengan desain elegan dan bahan nyaman.'}
</p> </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 */} {/* Price */}
<div className="bg-amber-50/50 p-4 rounded-xl"> <div className="bg-amber-50/50 p-4 rounded-xl">
<p className="text-xs text-slate-500 mb-1">Harga</p> <p className="text-xs text-slate-500 mb-1">Harga</p>
<p className="text-2xl font-bold text-amber-600"> <p className="text-2xl font-bold text-amber-600">
Rp {formatRupiah(currentPrice)} Rp {formatRupiah(currentPrice)}
</p> </p>
{selectedPriceType === 'wholesale' && wholesalePrice > 0 && (
<p className="text-xs text-slate-400 mt-1">
Harga retail: Rp {formatRupiah(retailPrice)}
</p>
)}
</div> </div>
{/* Variant Selection */} {/* 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> <p className="text-xs font-semibold text-slate-700 mb-2">Varian</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{variants.map((variant) => ( {variants.map((variant) => (
<button <Button
key={variant.id} key={variant.id}
onClick={() => setSelectedVariant(variant)} variant={activeVariant?.id === variant.id ? 'default' : 'outline'}
className={`px-4 py-2 text-xs font-semibold rounded-xl border transition-all ${ size="sm"
activeVariant?.id === variant.id onClick={() => handleVariantChange(variant)}
? 'bg-amber-600 text-white border-transparent' className={activeVariant?.id === variant.id
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300' ? 'bg-amber-600 text-white border-transparent'
}`} : 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
}
> >
{variant.name} {variant.name}
<span className="ml-1 text-[10px] opacity-70"> <span className="ml-1 text-[10px] opacity-70">
({variant.stock}) ({variant.stock})
</span> </span>
</button> </Button>
))} ))}
</div> </div>
</div> </div>
@ -151,26 +181,28 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
<div> <div>
<p className="text-xs font-semibold text-slate-700 mb-2">Jenis Harga</p> <p className="text-xs font-semibold text-slate-700 mb-2">Jenis Harga</p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
variant={selectedPriceType === 'retail' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedPriceType('retail')} onClick={() => setSelectedPriceType('retail')}
className={`px-4 py-2 text-xs font-semibold rounded-xl border transition-all ${ className={selectedPriceType === 'retail'
selectedPriceType === 'retail' ? 'bg-amber-600 text-white border-transparent'
? 'bg-amber-600 text-white border-transparent' : 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300' }
}`}
> >
Retail Retail
</button> </Button>
<button <Button
variant={selectedPriceType === 'wholesale' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedPriceType('wholesale')} onClick={() => setSelectedPriceType('wholesale')}
className={`px-4 py-2 text-xs font-semibold rounded-xl border transition-all ${ className={selectedPriceType === 'wholesale'
selectedPriceType === 'wholesale' ? 'bg-amber-600 text-white border-transparent'
? 'bg-amber-600 text-white border-transparent' : 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300' }
}`}
> >
Grosir Grosir
</button> </Button>
</div> </div>
</div> </div>
@ -178,19 +210,21 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
<div> <div>
<p className="text-xs font-semibold text-slate-700 mb-2">Jumlah</p> <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"> <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))} onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="p-1 text-slate-500 hover:text-slate-800"
> >
<Minus className="w-4 h-4" /> <Minus className="w-4 h-4" />
</button> </Button>
<span className="text-sm font-semibold w-8 text-center">{quantity}</span> <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))} onClick={() => setQuantity(Math.min(maxStock, quantity + 1))}
className="p-1 text-slate-500 hover:text-slate-800"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</button> </Button>
</div> </div>
<p className="text-[10px] text-slate-400 mt-1">Stok tersedia: {maxStock}</p> <p className="text-[10px] text-slate-400 mt-1">Stok tersedia: {maxStock}</p>
</div> </div>
@ -205,14 +239,19 @@ export default function ProductModal({ product, onClose }: ProductModalProps) {
</div> </div>
</div> </div>
{/* WhatsApp Order */} {/* Actions */}
<button <div className="flex gap-3">
onClick={handleWhatsAppOrder} <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">
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" <ShoppingBag className="w-4 h-4 mr-2" />
> Keranjang
<Phone className="w-4 h-4 mr-2" /> </Button>
Pesan Via WhatsApp <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">
</button> <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> </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 { AppLogo, AppLogoIcon } from '@/components/brand';
import { Breadcrumbs } from '@/components/layout'; import { Breadcrumbs } from '@/components/layout';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@ -22,17 +20,14 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { UserMenuContent } from '@/components/user'; import { UserMenuContent } from '@/components/user';
import { useCurrentUrl } from '@/hooks/use-current-url'; import { useCurrentUrl } from '@/hooks/use-current-url';
import { useInitials } from '@/hooks/use-initials'; import { useInitials } from '@/hooks/use-initials';
import { cn, toUrl } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import type { BreadcrumbItem, NavItem } from '@/types'; import type { BreadcrumbItem, NavItem } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { LayoutGrid, Menu } from 'lucide-react';
type Props = { type Props = {
breadcrumbs?: BreadcrumbItem[]; 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 = const activeItemStyles =
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'; 'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
@ -110,23 +92,6 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
</Link> </Link>
))} ))}
</div> </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>
</div> </div>
</SheetContent> </SheetContent>
@ -176,40 +141,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
</div> </div>
<div className="ml-auto flex items-center space-x-2"> <div className="ml-auto flex items-center space-x-2">
<div className="relative flex items-center space-x-1"> <DropdownMenu>k
<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>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" 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); 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 type DateFormatStyle = 'full' | 'short' | 'datetime';
export function formatDate(dateString: string, style: DateFormatStyle = 'full'): string { export function formatDate(dateString: string, style: DateFormatStyle = 'full'): string {

View File

@ -515,7 +515,7 @@ export default function Analysis({
} }
if (hasAnyRole(['admin_toko', 'direktur'])) { 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')) { if (hasRole('marketing')) {

View File

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

View File

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

View File

@ -32,6 +32,7 @@ import {
edit as productEdit, edit as productEdit,
index as productIndex, index as productIndex,
toggleStatus, toggleStatus,
toggleFeatured as productToggleFeatured,
approve as productApprove, approve as productApprove,
reject as productReject, reject as productReject,
resubmit as productResubmit, resubmit as productResubmit,
@ -63,6 +64,7 @@ type Props = {
name?: string; name?: string;
stock?: string; stock?: string;
category?: string; category?: string;
featured?: string;
}; };
}; };
@ -160,7 +162,8 @@ export default function ProductIndex({ products, categories, productNames, filte
filters.status || filters.status ||
filters.name || filters.name ||
filters.stock || filters.stock ||
filters.category, filters.category ||
filters.featured,
)} )}
onClear={clearFilters} onClear={clearFilters}
> >
@ -265,6 +268,25 @@ export default function ProductIndex({ products, categories, productNames, filte
</ComboboxContent> </ComboboxContent>
</Combobox> </Combobox>
</div> </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> </FilterPopover>
); );
@ -321,6 +343,7 @@ export default function ProductIndex({ products, categories, productNames, filte
onDelete={(p) => setDeleting(p)} onDelete={(p) => setDeleting(p)}
onReject={(p) => setRejecting(p)} onReject={(p) => setRejecting(p)}
toggleStatusUrl={(id) => toggleStatus.url(id)} toggleStatusUrl={(id) => toggleStatus.url(id)}
toggleFeaturedUrl={(id) => productToggleFeatured.url(id)}
approveUrl={(id) => productApprove.url(id)} approveUrl={(id) => productApprove.url(id)}
resubmitUrl={(id) => productResubmit.url(id)} resubmitUrl={(id) => productResubmit.url(id)}
/> />

View File

@ -42,6 +42,7 @@ export type ProductCardRowParams = {
onDelete: (product: Product) => void; onDelete: (product: Product) => void;
onReject: (product: Product) => void; onReject: (product: Product) => void;
toggleStatusUrl: (id: number) => string; toggleStatusUrl: (id: number) => string;
toggleFeaturedUrl: (id: number) => string;
approveUrl: (id: number) => string; approveUrl: (id: number) => string;
resubmitUrl: (id: number) => string; resubmitUrl: (id: number) => string;
}; };
@ -55,6 +56,7 @@ export function ProductCardRow({
onDelete, onDelete,
onReject, onReject,
toggleStatusUrl, toggleStatusUrl,
toggleFeaturedUrl,
approveUrl, approveUrl,
resubmitUrl, resubmitUrl,
}: ProductCardRowParams) { }: ProductCardRowParams) {
@ -173,6 +175,16 @@ export function ProductCardRow({
</span> </span>
)} )}
</div> </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> </div>
{/* Actions */} {/* Actions */}

View File

@ -1,23 +1,28 @@
import { Head, Link, router, InfiniteScroll } from '@inertiajs/react'; import ProductCard from '@/components/home/ProductCard';
import { useState, useCallback, useRef } from 'react'; 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 { import {
ArrowRight,
Check,
ChevronRight,
Info,
LogIn,
Mail,
MapPin,
Phone,
Plus,
Search, Search,
ShoppingBag, ShoppingBag,
Plus,
ArrowRight,
Star, Star,
Info,
ChevronRight,
Phone,
MapPin,
Mail,
LogIn,
Check,
} from 'lucide-react'; } from 'lucide-react';
import { formatRupiah } from '@/lib/rupiah'; import { useCallback, useEffect, useRef, useState } from 'react';
import ProductCard from '@/components/home/ProductCard';
import ProductModal from '@/components/home/ProductModal';
import type { Product, Category, HomepageData, ProductPrice, Paginated } from '@/types/homepage';
type Props = { type Props = {
appName: string; appName: string;
@ -52,30 +57,43 @@ export default function Welcome({
const [searchInput, setSearchInput] = useState(filters.search); const [searchInput, setSearchInput] = useState(filters.search);
const [selectedCategory, setSelectedCategory] = useState(filters.category); const [selectedCategory, setSelectedCategory] = useState(filters.category);
const [activeProduct, setActiveProduct] = useState<Product | null>(null); 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 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 getProductImage = (product: Product): string => {
const fallbacks: Record<string, string[]> = { for (const variant of product.product_variants) {
daster: [ if (variant.photo_urls && variant.photo_urls.length > 0) {
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80', return variant.photo_urls[0];
], }
'setelan-celana': [ }
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80', return FALLBACK_IMAGE;
],
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];
}; };
const getProductPriceRange = (product: Product): string => { const getProductPriceRange = (product: Product): string => {
@ -145,7 +163,6 @@ export default function Welcome({
fetchProducts('', ''); fetchProducts('', '');
}, [fetchProducts]); }, [fetchProducts]);
// Use first 3 products from current page as featured for hero
const featuredProducts = products.data.slice(0, 3); const featuredProducts = products.data.slice(0, 3);
return ( return (
@ -159,30 +176,42 @@ export default function Welcome({
{/* Sticky Header */} {/* Sticky Header */}
<header className="sticky top-0 z-40 bg-white/70 backdrop-blur-lg border-b border-amber-100/50 transition-all"> <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="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">
<Link href="/" className="flex items-center space-x-2"> <img src="/assets/logo.png" alt={appName} className="h-12 w-auto" />
<span className="font-serif text-2xl font-bold tracking-widest text-amber-600"> </Link>
{appName}
</span>
</Link>
</div>
<nav className="hidden lg:flex items-center space-x-8 text-xs font-semibold uppercase tracking-widest text-slate-600"> <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> { id: 'hero', label: 'Beranda' },
<a href="#about" className="hover:text-amber-600 transition-colors">Tentang Kami</a> { id: 'catalog', label: 'Produk' },
<a href="#order-guide" className="hover:text-amber-600 transition-colors">Cara Pesan</a> { id: 'about', label: 'Tentang Kami' },
<a href="#contact" className="hover:text-amber-600 transition-colors">Hubungi Kami</a> { 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> </nav>
<div className="flex items-center space-x-4"> <div className="flex items-center gap-2">
<a <Button variant="ghost" size="icon" onClick={() => setCartOpen(true)} className="relative">
href="/admin/dashboard" <ShoppingBag className="w-4.5 h-4.5 text-amber-600" />
className="p-2 rounded-full hover:bg-amber-50 text-slate-600 transition-colors" {totalItems > 0 && (
aria-label="Login" <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}
<LogIn className="w-4.5 h-4.5 text-amber-600" /> </span>
</a> )}
</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>
</div> </div>
</header> </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"> <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 */} {/* Left Column */}
<div className="lg:col-span-5 flex flex-col justify-center space-y-6 lg:pr-6"> <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"> <Badge variant="secondary" className="w-fit bg-amber-500/10 text-amber-700 uppercase tracking-widest font-bold px-3 py-1 shadow-sm">
<span>{homepage.hero_badge}</span> Hadir dengan Gaya Terbaru
</div> </Badge>
<h1 className="text-5xl md:text-6xl font-serif leading-[1.1] tracking-tight font-light select-none text-slate-800"> <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 /> Nyaman<br />
{homepage.hero_title_line2}{' '} dalam setiap{' '}
<span className="font-normal italic text-amber-600 bg-amber-100/20 px-2 rounded-lg"> <span className="font-normal italic text-amber-600 bg-amber-100/20 px-2 rounded-lg">
{homepage.hero_title_highlight} Penampilan
</span> </span>
</h1> </h1>
<p className="text-sm text-slate-500 max-w-md leading-relaxed font-light"> <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> </p>
<div className="flex items-center space-x-4 pt-4"> <div className="flex items-center space-x-4 pt-4">
<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">
href="#catalog" <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" Beli Sekarang
> <ArrowRight className="w-3.5 h-3.5 ml-2 transform group-hover:translate-x-1 transition-transform" />
{homepage.hero_cta_primary_text} </a>
<ArrowRight className="w-3.5 h-3.5 ml-2 transform group-hover:translate-x-1 transition-transform" /> </Button>
</a> <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 <a href="#about">Tentang Kami</a>
href="#about" </Button>
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>
</div> </div>
</div> </div>
{/* Center Column: Visual */} {/* Center Column: Visual */}
<div className="lg:col-span-7 relative flex justify-center items-center h-[500px] lg:h-[650px] w-full"> <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 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-[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 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>
<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]"> <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 <img
src={homepage.hero_image_url || 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80'} 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 */} {/* 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="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"> <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 className="w-1 h-1 rounded-full bg-amber-300" />
<span>{homepage.scroll_tagline}</span> <span>Bahan Adem & Lembut</span>
</div> </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> <span>Lihat katalog lengkap</span>
<ChevronRight className="w-3.5 h-3.5 rotate-90 text-amber-600" /> <ChevronRight className="w-3.5 h-3.5 rotate-90 text-amber-600" />
</a> </Button>
</div> </div>
</section> </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"> <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="space-y-6 mb-12">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h2 className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.catalog_badge}</h2> <Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
<h3 className="text-3xl font-serif text-slate-800">{homepage.catalog_title}</h3> Katalog Eksklusif
<p className="text-sm text-slate-500 max-w-lg mx-auto font-light">{homepage.catalog_description}</p> </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> </div>
{/* Filters */} {/* 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="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"> <div className="relative w-full md:w-80">
<input <Input
value={searchInput} value={searchInput}
onChange={(e) => handleSearchChange(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
type="text" type="text"
placeholder={homepage.catalog_search_placeholder} placeholder="Cari produk..."
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" 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" /> <Search className="absolute right-3 top-3.5 w-4 h-4 text-slate-400" />
</div> </div>
<div className="flex flex-wrap gap-2 w-full md:w-auto"> <div className="flex flex-wrap gap-2 w-full md:w-auto">
<button <Button
variant={!selectedCategory ? 'default' : 'outline'}
size="sm"
onClick={() => handleCategoryChange(null)} onClick={() => handleCategoryChange(null)}
className={`shrink-0 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider rounded-xl border transition-all whitespace-nowrap ${ className={`shrink-0 text-[11px] font-semibold uppercase tracking-wider rounded-xl transition-all whitespace-nowrap ${!selectedCategory
!selectedCategory ? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50'
? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50' : 'bg-transparent text-slate-600 border-slate-200 hover'
: 'bg-transparent text-slate-600 border-slate-200 hover' }`}
}`}
> >
Semua Semua
</button> </Button>
{categories.map((category) => ( {categories.map((category) => (
<button <Button
key={category.id} key={category.id}
variant={selectedCategory === category.slug ? 'default' : 'outline'}
size="sm"
onClick={() => handleCategoryChange(selectedCategory === category.slug ? null : category.slug)} 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 ${ className={`shrink-0 text-[11px] font-semibold uppercase tracking-wider rounded-xl transition-all whitespace-nowrap ${selectedCategory === category.slug
selectedCategory === category.slug ? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50'
? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50' : 'bg-transparent text-slate-600 border-slate-200 hover'
: 'bg-transparent text-slate-600 border-slate-200 hover' }`}
}`}
> >
{category.name} {category.name}
</button> </Button>
))} ))}
</div> </div>
</div> </div>
@ -384,6 +419,7 @@ export default function Welcome({
key={product.id} key={product.id}
product={product} product={product}
onQuickView={setActiveProduct} onQuickView={setActiveProduct}
contactPhone={contactPhone}
/> />
))} ))}
</div> </div>
@ -394,12 +430,9 @@ export default function Welcome({
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
Mohon maaf, kami tidak menemukan pakaian yang cocok dengan kata kunci pencarian Anda. Mohon maaf, kami tidak menemukan pakaian yang cocok dengan kata kunci pencarian Anda.
</p> </p>
<button <Button variant="ghost" onClick={handleResetFilters} className="mt-2 text-xs font-bold uppercase tracking-widest text-amber-600 underline underline-offset-4">
onClick={handleResetFilters}
className="mt-2 text-xs font-bold uppercase tracking-widest text-amber-600 underline underline-offset-4"
>
Reset Pencarian Reset Pencarian
</button> </Button>
</div> </div>
)} )}
</InfiniteScroll> </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"> <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="max-w-7xl mx-auto px-6 space-y-12">
<div className="text-center space-y-3 max-w-lg mx-auto"> <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> <Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
<h3 className="text-4xl font-serif leading-tight">{homepage.gallery_title}</h3> Galeri Kami
<p className="text-sm text-slate-500 leading-relaxed font-light">{homepage.gallery_description}</p> </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> </div>
{homepage.gallery_images.length > 0 ? ( {homepage.gallery_images.length > 0 ? (
@ -441,13 +476,20 @@ export default function Welcome({
{/* Order Guide Section */} {/* Order Guide Section */}
<section id="order-guide" className="max-w-7xl mx-auto px-6 py-20 border-b border-amber-100/30"> <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"> <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> <Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
<h4 className="text-3xl font-serif">{homepage.order_guide_title}</h4> Langkah Pemesanan
<p className="text-sm text-slate-500 max-w-md mx-auto font-light">{homepage.order_guide_description}</p> </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>
<div className="grid grid-cols-1 md:grid-cols-4 gap-8"> <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 <div
key={index} 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" 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>
<div className="lg:col-span-6 space-y-6"> <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> <Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
<h3 className="text-3xl font-serif">{homepage.about_title}</h3> 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"> <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> </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"> <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" /> <Check className="w-4 h-4 text-emerald-500" />
<span>{feature}</span> <span>{feature}</span>
@ -496,13 +540,15 @@ export default function Welcome({
{/* Contact Section */} {/* Contact Section */}
<section id="contact" className="max-w-7xl mx-auto px-6 py-20"> <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="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="space-y-6"> <div className="p-8 space-y-6 flex flex-col justify-center">
<span className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.contact_badge}</span> <Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1 w-fit">
<h3 className="text-3xl font-serif">{homepage.contact_title}</h3> Kontak Kami
<p className="text-sm text-slate-500 leading-relaxed font-light">{homepage.contact_description}</p> </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 && ( {contactPhone && (
<div className="flex items-center space-x-3 text-sm"> <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"> <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> </div>
<div className="h-80 lg:h-auto">
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 space-y-4"> <iframe
<h4 className="font-bold text-base">{homepage.contact_form_title}</h4> 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"
<div className="space-y-3"> width="100%"
<input height="100%"
type="text" style={{ border: 0 }}
placeholder="Nama Anda" allowFullScreen
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" loading="lazy"
/> referrerPolicy="no-referrer-when-downgrade"
<textarea className="w-full h-full min-h-[320px]"
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> </div>
</div> </div>
</section> </section>
@ -561,13 +594,9 @@ export default function Welcome({
<footer className="bg-white text-slate-600 py-16 border-t border-amber-100/30"> <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="max-w-7xl mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center space-x-2"> <img src="/assets/logo.png" alt={appName} className="h-10 w-auto" />
<span className="font-serif text-xl font-bold tracking-widest text-amber-600">
{appName}
</span>
</div>
<p className="text-xs text-slate-400 leading-relaxed font-light"> <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> </p>
</div> </div>
@ -610,12 +639,13 @@ export default function Welcome({
</div> </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"> <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> </div>
</footer> </footer>
{/* Product Modal */} {/* 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> </div>
</> </>
); );

View File

@ -14,6 +14,7 @@ export type ProductVariant = {
stock: number; stock: number;
formatted_stock: string; formatted_stock: string;
product_prices: ProductPrice[]; product_prices: ProductPrice[];
photo_urls: string[];
}; };
export type ProductCategory = { export type ProductCategory = {
@ -42,40 +43,9 @@ export type Category = {
}; };
export type HomepageData = { 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_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_image_url: string | null;
about_features: string[]; gallery_images: string[];
contact_badge: string;
contact_title: string;
contact_description: string;
contact_form_title: string;
footer_description: string;
footer_copyright: string;
}; };
export type Paginated<T> = { 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::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-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}/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}/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'); 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($developer, WebPushNotification::class);
Notification::assertSentTo($kasbonUser, 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 () { 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($developer, WebPushNotification::class);
Notification::assertSentTo($kasbonUser, 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);
}); });
/* /*