Compare commits
No commits in common. "b905c0a2c6d65b2493e10991179cf5e457c9d21d" and "d5f2a66a142283caf98343f26d482cc897dd315e" have entirely different histories.
b905c0a2c6
...
d5f2a66a14
@ -46,9 +46,9 @@ ### `product_categories` → Category (Pivot)
|
||||
- Relations: category(BelongsTo→Category), product(BelongsTo→Product)
|
||||
|
||||
### `products` → Product
|
||||
`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `is_featured`(bool,default:false) `rejection_reason`(text,null) `created_at` `updated_at` `deleted_at`
|
||||
- Casts: status(ProductStatus), is_featured(bool)
|
||||
- Scopes: active(), draft(), featured(), inactive(), pending(), rejected()
|
||||
`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `rejection_reason`(text,null) `created_at` `updated_at` `deleted_at`
|
||||
- Casts: status(ProductStatus)
|
||||
- Scopes: active(), draft(), inactive(), pending(), rejected()
|
||||
- Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant)
|
||||
|
||||
### `product_variants` → ProductVariant
|
||||
@ -288,7 +288,7 @@ ## Enums
|
||||
|------|--------|---------|
|
||||
| `CashTransactionType` | deposit, expense, transfer, withdrawal | cash_transactions.type |
|
||||
| `CuttingStatus` | in_progress, completed, cancelled | cuttings.status |
|
||||
| `EmployeeAdvanceStatus` | pending, disbursed, partial, repaid, rejected | employee_advances.status |
|
||||
| `EmployeeAdvanceStatus` | pending, approved, rejected, paid, cancelled | employee_advances.status |
|
||||
| `EmploymentStatus` | full_time, part_time, contract, internship, resigned | employees.employment_status |
|
||||
| `Gender` | male, female | user_profiles.gender |
|
||||
| `LeaveRequestStatus` | pending, approved, rejected, cancelled | leave_requests.status |
|
||||
@ -336,7 +336,6 @@ ### Master
|
||||
| `RawMaterialVariantRequest` | `variant` | `required, string, max:200` | ✅ varchar(200) |
|
||||
| `RawMaterialVariantRequest` | `price` | `required, integer, min:0` | ✅ uint |
|
||||
| `ProductRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
|
||||
| `ProductRequest` | `is_featured` | `nullable, boolean` | ✅ bool |
|
||||
| `ProductVariantRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
|
||||
|
||||
### Finance
|
||||
|
||||
@ -8,20 +8,20 @@ enum EmployeeAdvanceStatus: string
|
||||
{
|
||||
use HasValues;
|
||||
|
||||
case DISBURSED = 'disbursed';
|
||||
case PARTIAL = 'partial';
|
||||
case APPROVED = 'approved';
|
||||
case CANCELLED = 'cancelled';
|
||||
case PAID = 'paid';
|
||||
case PENDING = 'pending';
|
||||
case REJECTED = 'rejected';
|
||||
case REPAID = 'repaid';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DISBURSED => 'Dikeluarkan',
|
||||
self::PARTIAL => 'Dicicil',
|
||||
self::APPROVED => 'Disetujui',
|
||||
self::CANCELLED => 'Dibatalkan',
|
||||
self::PAID => 'Dibayar',
|
||||
self::PENDING => 'Menunggu',
|
||||
self::REJECTED => 'Ditolak',
|
||||
self::REPAID => 'Lunas',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,7 +71,6 @@ enum Permission: string
|
||||
case PRODUCTS_UPDATE = 'products.update';
|
||||
case PRODUCTS_DELETE = 'products.delete';
|
||||
case PRODUCTS_TOGGLE_STATUS = 'products.toggle_status';
|
||||
case PRODUCTS_TOGGLE_FEATURED = 'products.toggle_featured';
|
||||
case PRODUCTS_TRANSFER_STOCK = 'products.transfer_stock';
|
||||
case PRODUCTS_VIEW_STOCK_MUTATIONS = 'products.view_stock_mutations';
|
||||
|
||||
|
||||
@ -24,11 +24,11 @@ public function index(PaginatedRequest $request): Response
|
||||
return Inertia::render('admin/master/product/index', [
|
||||
'products' => $this->service->paginated(
|
||||
...$request->validatedWithDefaults(),
|
||||
filters: $request->only(['status', 'stock', 'category', 'name', 'featured']),
|
||||
filters: $request->only(['status', 'stock', 'category', 'name']),
|
||||
),
|
||||
'categories' => $this->categoryService->getAll(),
|
||||
'productNames' => $this->service->getNames(),
|
||||
'filters' => $request->only(['status', 'stock', 'category', 'name', 'featured']),
|
||||
'filters' => $request->only(['status', 'stock', 'category', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -87,16 +87,6 @@ public function toggleStatus(Product $product): RedirectResponse
|
||||
return back();
|
||||
}
|
||||
|
||||
public function toggleFeatured(Product $product): RedirectResponse
|
||||
{
|
||||
$this->service->toggleFeatured($product);
|
||||
$featured = $product->fresh()->is_featured;
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => $featured ? 'Produk berhasil ditampilkan di halaman depan.' : 'Produk berhasil disembunyikan dari halaman depan.']);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function approve(Product $product): RedirectResponse
|
||||
{
|
||||
$this->service->approve($product);
|
||||
|
||||
@ -33,12 +33,10 @@ public function __invoke(Request $request): Response
|
||||
|
||||
$productsQuery = Product::select(['id', 'name', 'slug', 'description', 'status'])
|
||||
->active()
|
||||
->featured()
|
||||
->with([
|
||||
'categories:id,name,slug',
|
||||
'productVariants:id,product_id,name,stock',
|
||||
'productVariants.productPrices:id,variant_id,type,price',
|
||||
'productVariants.media',
|
||||
]);
|
||||
|
||||
if ($search !== '') {
|
||||
@ -54,23 +52,9 @@ public function __invoke(Request $request): Response
|
||||
});
|
||||
}
|
||||
|
||||
$s3Service = $this->s3Service;
|
||||
$products = Inertia::scroll(function () use ($productsQuery, $s3Service) {
|
||||
$paginator = $productsQuery->orderBy('created_at', 'desc')->paginate(12);
|
||||
|
||||
$paginator->getCollection()->each(function ($product) use ($s3Service) {
|
||||
$product->productVariants->each(function ($variant) use ($s3Service) {
|
||||
$media = $variant->getMedia('images');
|
||||
$variant->photo_urls = $media->map(
|
||||
fn ($m) => str_starts_with($m->getPath(), 'http')
|
||||
? $m->getPath()
|
||||
: $s3Service->getTemporaryUrl($m->getPath(), 60)
|
||||
)->toArray();
|
||||
});
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
});
|
||||
$products = Inertia::scroll(
|
||||
fn () => $productsQuery->orderBy('created_at', 'desc')->paginate(12)
|
||||
);
|
||||
|
||||
$galleryImages = array_map(
|
||||
fn ($key) => str_starts_with($key, 'http') ? $key : $this->s3Service->getTemporaryUrl($key, 60),
|
||||
@ -87,13 +71,44 @@ public function __invoke(Request $request): Response
|
||||
'facebookUrl' => $socialMedia->facebook_url,
|
||||
'tiktokUrl' => $socialMedia->tiktok_url,
|
||||
'homepage' => [
|
||||
'hero_badge' => $homepage->hero_badge,
|
||||
'hero_title_line1' => $homepage->hero_title_line1,
|
||||
'hero_title_line2' => $homepage->hero_title_line2,
|
||||
'hero_title_highlight' => $homepage->hero_title_highlight,
|
||||
'hero_description' => $homepage->hero_description,
|
||||
'hero_cta_primary_text' => $homepage->hero_cta_primary_text,
|
||||
'hero_cta_secondary_text' => $homepage->hero_cta_secondary_text,
|
||||
'hero_image_url' => $homepage->hero_image_url
|
||||
? (str_starts_with($homepage->hero_image_url, 'http') ? $homepage->hero_image_url : $this->s3Service->getTemporaryUrl($homepage->hero_image_url, 60))
|
||||
: null,
|
||||
'hero_bg_text_left' => $homepage->hero_bg_text_left,
|
||||
'hero_bg_text_right' => $homepage->hero_bg_text_right,
|
||||
'scroll_hashtag' => $homepage->scroll_hashtag,
|
||||
'scroll_tagline' => $homepage->scroll_tagline,
|
||||
'catalog_badge' => $homepage->catalog_badge,
|
||||
'catalog_title' => $homepage->catalog_title,
|
||||
'catalog_description' => $homepage->catalog_description,
|
||||
'catalog_search_placeholder' => $homepage->catalog_search_placeholder,
|
||||
'gallery_badge' => $homepage->gallery_badge,
|
||||
'gallery_title' => $homepage->gallery_title,
|
||||
'gallery_description' => $homepage->gallery_description,
|
||||
'gallery_images' => $galleryImages,
|
||||
'order_guide_badge' => $homepage->order_guide_badge,
|
||||
'order_guide_title' => $homepage->order_guide_title,
|
||||
'order_guide_description' => $homepage->order_guide_description,
|
||||
'order_steps' => $homepage->order_steps,
|
||||
'about_badge' => $homepage->about_badge,
|
||||
'about_title' => $homepage->about_title,
|
||||
'about_image_url' => $homepage->about_image_url
|
||||
? (str_starts_with($homepage->about_image_url, 'http') ? $homepage->about_image_url : $this->s3Service->getTemporaryUrl($homepage->about_image_url, 60))
|
||||
: null,
|
||||
'gallery_images' => $galleryImages,
|
||||
'about_features' => $homepage->about_features,
|
||||
'contact_badge' => $homepage->contact_badge,
|
||||
'contact_title' => $homepage->contact_title,
|
||||
'contact_description' => $homepage->contact_description,
|
||||
'contact_form_title' => $homepage->contact_form_title,
|
||||
'footer_description' => $homepage->footer_description,
|
||||
'footer_copyright' => $homepage->footer_copyright,
|
||||
],
|
||||
'categories' => $categories,
|
||||
'products' => $products,
|
||||
@ -102,8 +117,8 @@ public function __invoke(Request $request): Response
|
||||
'category' => $category,
|
||||
],
|
||||
'seo' => [
|
||||
'title' => $system->app_name.' - Koleksi Segar 2026',
|
||||
'description' => 'Selamat datang di '.$system->app_name.'. Temukan keanggunan motif batik modern dan setelan pakaian santai berkualitas premium.',
|
||||
'title' => $system->app_name.' - '.$homepage->hero_badge,
|
||||
'description' => $homepage->hero_description,
|
||||
'image' => url('/assets/logo.png'),
|
||||
'url' => url('/'),
|
||||
],
|
||||
|
||||
@ -37,7 +37,6 @@ public function rules(): array
|
||||
],
|
||||
'description' => ['nullable', 'string'],
|
||||
'status' => ['nullable', Rule::in(ProductStatus::values())],
|
||||
'is_featured' => ['nullable', 'boolean'],
|
||||
'category_ids' => ['required', 'array', 'min:1'],
|
||||
'category_ids.*' => [Rule::exists('categories', 'id')],
|
||||
'use_same_price' => ['nullable', 'boolean'],
|
||||
@ -65,7 +64,6 @@ public function attributes(): array
|
||||
'name' => 'nama produk',
|
||||
'description' => 'deskripsi',
|
||||
'status' => 'status',
|
||||
'is_featured' => 'ditampilkan di halaman depan',
|
||||
'category_ids' => 'kategori',
|
||||
'variants' => 'varian',
|
||||
'variants.*.name' => 'nama varian',
|
||||
|
||||
@ -75,21 +75,21 @@ protected function statusLabel(): Attribute
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function disbursed(Builder $query): void
|
||||
protected function approved(Builder $query): void
|
||||
{
|
||||
$query->where('status', EmployeeAdvanceStatus::DISBURSED);
|
||||
$query->where('status', EmployeeAdvanceStatus::APPROVED);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function partial(Builder $query): void
|
||||
protected function cancelled(Builder $query): void
|
||||
{
|
||||
$query->where('status', EmployeeAdvanceStatus::PARTIAL);
|
||||
$query->where('status', EmployeeAdvanceStatus::CANCELLED);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function repaid(Builder $query): void
|
||||
protected function paid(Builder $query): void
|
||||
{
|
||||
$query->where('status', EmployeeAdvanceStatus::REPAID);
|
||||
$query->where('status', EmployeeAdvanceStatus::PAID);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
|
||||
@ -26,7 +26,6 @@ protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => ProductStatus::class,
|
||||
'is_featured' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@ -56,12 +55,6 @@ protected function draft(Builder $query): void
|
||||
$query->where('status', ProductStatus::DRAFT);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function featured(Builder $query): void
|
||||
{
|
||||
$query->where('is_featured', true);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function inactive(Builder $query): void
|
||||
{
|
||||
|
||||
@ -63,13 +63,13 @@ public function store(array $data): EmployeeAdvance
|
||||
|
||||
public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance
|
||||
{
|
||||
if ($employeeAdvance->status === EmployeeAdvanceStatus::REPAID) {
|
||||
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => 'Kasbon yang sudah dikembalikan tidak dapat diedit.',
|
||||
'amount' => 'Kasbon yang sudah dibayar tidak dapat diedit.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($employeeAdvance->status === EmployeeAdvanceStatus::DISBURSED && $employeeAdvance->cash_transaction_id) {
|
||||
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) {
|
||||
$oldAmount = $employeeAdvance->amount;
|
||||
$newAmount = $data['amount'];
|
||||
|
||||
@ -113,7 +113,7 @@ public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeA
|
||||
public function destroy(EmployeeAdvance $employeeAdvance): bool
|
||||
{
|
||||
return DB::transaction(function () use ($employeeAdvance) {
|
||||
if ($employeeAdvance->status === EmployeeAdvanceStatus::DISBURSED && $employeeAdvance->cash_transaction_id) {
|
||||
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) {
|
||||
$this->creditCash(
|
||||
$employeeAdvance->amount,
|
||||
'Pembatalan kasbon: '.$employeeAdvance->description,
|
||||
@ -122,7 +122,7 @@ public function destroy(EmployeeAdvance $employeeAdvance): bool
|
||||
$employeeAdvance->cashTransaction()->delete();
|
||||
}
|
||||
|
||||
if ($employeeAdvance->status === EmployeeAdvanceStatus::REPAID || $employeeAdvance->status === EmployeeAdvanceStatus::PARTIAL) {
|
||||
if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) {
|
||||
$this->creditCash(
|
||||
$employeeAdvance->amount,
|
||||
'Pembatalan kasbon: '.$employeeAdvance->description,
|
||||
@ -159,7 +159,7 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
|
||||
|
||||
$employeeAdvance->update([
|
||||
'cash_transaction_id' => $cashTransaction->id,
|
||||
'status' => EmployeeAdvanceStatus::DISBURSED,
|
||||
'status' => EmployeeAdvanceStatus::APPROVED,
|
||||
'verified_by_id' => auth()->id(),
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
@ -169,8 +169,8 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
|
||||
|
||||
NotificationService::notify(
|
||||
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
|
||||
title: 'Kasbon Dikeluarkan',
|
||||
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui dan dikeluarkan'.' oleh '.auth()->user()->full_name.'.',
|
||||
title: 'Kasbon Disetujui',
|
||||
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.finance.employee-advances.index'),
|
||||
additionalUser: $employeeAdvance->employee->user ?? null,
|
||||
);
|
||||
@ -208,7 +208,7 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva
|
||||
|
||||
$employeeAdvance->update([
|
||||
'paid_amount' => $newPaidAmount,
|
||||
'status' => $isFullyPaid ? EmployeeAdvanceStatus::REPAID : EmployeeAdvanceStatus::PARTIAL,
|
||||
'status' => $isFullyPaid ? EmployeeAdvanceStatus::PAID : $employeeAdvance->status,
|
||||
'paid_by_id' => $isFullyPaid ? auth()->id() : $employeeAdvance->paid_by_id,
|
||||
'paid_at' => $isFullyPaid ? now() : $employeeAdvance->paid_at,
|
||||
]);
|
||||
@ -216,13 +216,13 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva
|
||||
return $employeeAdvance;
|
||||
});
|
||||
|
||||
$notificationBody = $employeeAdvance->status === EmployeeAdvanceStatus::REPAID
|
||||
? 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dikembalikan lunas'.' oleh '.auth()->user()->full_name.'.'
|
||||
$notificationBody = $employeeAdvance->status === EmployeeAdvanceStatus::PAID
|
||||
? 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.'
|
||||
: 'Pembayaran kasbon sebesar Rp '.number_format($amount, 0, ',', '.').' oleh '.auth()->user()->full_name.'. Sisa: Rp '.number_format($employeeAdvance->amount - $employeeAdvance->paid_amount, 0, ',', '.').'.';
|
||||
|
||||
NotificationService::notify(
|
||||
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
|
||||
title: $employeeAdvance->status === EmployeeAdvanceStatus::REPAID ? 'Kasbon Dikembalikan' : 'Pembayaran Kasbon',
|
||||
title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon',
|
||||
body: $notificationBody,
|
||||
url: route('admin.finance.employee-advances.index'),
|
||||
additionalUser: $employeeAdvance->employee->user ?? null,
|
||||
|
||||
@ -35,7 +35,7 @@ public function getNames(): Collection
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$paginator = Product::query()
|
||||
->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason', 'is_featured'])
|
||||
->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason'])
|
||||
->with([
|
||||
'categories:id,name',
|
||||
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
|
||||
@ -49,7 +49,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
|
||||
$cq->where('categories.id', $categoryId);
|
||||
}))
|
||||
->when($filters['featured'] ?? null, fn ($q, $featured) => $q->where('is_featured', $featured === 'true'))
|
||||
->when(($filters['stock'] ?? null) === 'empty', function ($q) {
|
||||
$q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM product_variants WHERE product_variants.product_id = products.id AND product_variants.deleted_at IS NULL) = 0');
|
||||
})
|
||||
@ -434,15 +433,6 @@ public function toggleStatus(Product $product): void
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleFeatured(Product $product): void
|
||||
{
|
||||
$this->assertNotPending($product);
|
||||
|
||||
$product->update([
|
||||
'is_featured' => ! $product->is_featured,
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(Product $product): void
|
||||
{
|
||||
$product->update([
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\CashTransactionType;
|
||||
use App\Enums\EmployeeAdvanceStatus;
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentType;
|
||||
@ -287,7 +286,7 @@ public function getExpenseSummary(?string $startDate, ?string $endDate): array
|
||||
$expenseQuery = Expense::query();
|
||||
$this->applyDateFilter($expenseQuery, $startDate, $endDate, 'expenses.created_at');
|
||||
|
||||
$advanceQuery = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID);
|
||||
$advanceQuery = EmployeeAdvance::where('status', 'paid');
|
||||
$this->applyDateFilter($advanceQuery, $startDate, $endDate, 'employee_advances.created_at');
|
||||
|
||||
$purchaseQuery = Purchase::query();
|
||||
@ -327,7 +326,7 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate): array
|
||||
->get()
|
||||
->keyBy('month');
|
||||
|
||||
$advanceMonthly = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID);
|
||||
$advanceMonthly = EmployeeAdvance::where('status', 'paid');
|
||||
$this->applyDateFilter($advanceMonthly, $startDate, $endDate, 'employee_advances.created_at');
|
||||
|
||||
$advanceByMonth = (clone $advanceMonthly)->toBase()
|
||||
|
||||
@ -113,7 +113,7 @@ public function getExpenseSummary(): array
|
||||
->first();
|
||||
|
||||
$advanceTotal = EmployeeAdvance::whereDate('created_at', $today)
|
||||
->disbursed()
|
||||
->approved()
|
||||
->selectRaw('COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
|
||||
@ -6,11 +6,73 @@
|
||||
|
||||
class HomepageSettings extends Settings
|
||||
{
|
||||
public string $hero_badge;
|
||||
|
||||
public string $hero_title_line1;
|
||||
|
||||
public string $hero_title_line2;
|
||||
|
||||
public string $hero_title_highlight;
|
||||
|
||||
public string $hero_description;
|
||||
|
||||
public string $hero_cta_primary_text;
|
||||
|
||||
public string $hero_cta_secondary_text;
|
||||
|
||||
public ?string $hero_image_url;
|
||||
|
||||
public string $hero_bg_text_left;
|
||||
|
||||
public string $hero_bg_text_right;
|
||||
|
||||
public string $scroll_hashtag;
|
||||
|
||||
public string $scroll_tagline;
|
||||
|
||||
public string $catalog_badge;
|
||||
|
||||
public string $catalog_title;
|
||||
|
||||
public string $catalog_description;
|
||||
|
||||
public string $catalog_search_placeholder;
|
||||
|
||||
public string $gallery_badge;
|
||||
|
||||
public string $gallery_title;
|
||||
|
||||
public string $gallery_description;
|
||||
|
||||
public array $gallery_images;
|
||||
|
||||
public string $order_guide_badge;
|
||||
|
||||
public string $order_guide_title;
|
||||
|
||||
public string $order_guide_description;
|
||||
|
||||
public array $order_steps;
|
||||
|
||||
public string $about_badge;
|
||||
|
||||
public string $about_title;
|
||||
|
||||
public ?string $about_image_url;
|
||||
|
||||
public array $gallery_images;
|
||||
public array $about_features;
|
||||
|
||||
public string $contact_badge;
|
||||
|
||||
public string $contact_title;
|
||||
|
||||
public string $contact_description;
|
||||
|
||||
public string $contact_form_title;
|
||||
|
||||
public string $footer_description;
|
||||
|
||||
public string $footer_copyright;
|
||||
|
||||
public static function group(): string
|
||||
{
|
||||
|
||||
@ -17,7 +17,7 @@ public function definition(): array
|
||||
'paid_amount' => 0,
|
||||
'description' => fake()->sentence(),
|
||||
'due_date' => fake()->date(),
|
||||
'status' => fake()->randomElement(['pending', 'disbursed', 'partial', 'repaid', 'rejected']),
|
||||
'status' => fake()->randomElement(['pending', 'approved', 'paid', 'rejected', 'cancelled']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,6 @@ public function up(): void
|
||||
$table->string('slug', 200)->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('status', 20)->default(ProductStatus::ACTIVE->value);
|
||||
$table->boolean('is_featured')->default(false);
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
|
||||
@ -21,7 +21,7 @@ public function run(): void
|
||||
'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'],
|
||||
'categories' => ['view', 'create', 'update', 'delete'],
|
||||
'customers' => ['view', 'create', 'update', 'delete'],
|
||||
'products' => ['view', 'create', 'update', 'delete', 'toggle_status', 'toggle_featured', 'transfer_stock', 'view_stock_mutations'],
|
||||
'products' => ['view', 'create', 'update', 'delete', 'toggle_status', 'transfer_stock', 'view_stock_mutations'],
|
||||
'stocks' => ['view'],
|
||||
'orders' => ['view', 'create', 'update', 'delete', 'send', 'complete', 'cancel'],
|
||||
'cuttings' => ['view', 'create', 'update', 'delete', 'complete'],
|
||||
@ -70,7 +70,6 @@ public function run(): void
|
||||
'admin-toko' => array_values(array_filter($allPermissions, function ($p) {
|
||||
return in_array($p, [
|
||||
'dashboard.view',
|
||||
'dashboard.attendance',
|
||||
'dashboard.revenue',
|
||||
'dashboard.expense',
|
||||
'dashboard.orders_channel',
|
||||
@ -89,7 +88,6 @@ public function run(): void
|
||||
'analysis.profit_orders',
|
||||
'analysis.profit_hpp',
|
||||
'analysis.profit_gross',
|
||||
'analysis.profit_margin',
|
||||
'analysis.top_customers',
|
||||
'analysis.top_products',
|
||||
'analysis.top_suppliers',
|
||||
@ -126,7 +124,6 @@ public function run(): void
|
||||
'products.update',
|
||||
'products.delete',
|
||||
'products.toggle_status',
|
||||
'products.toggle_featured',
|
||||
'products.transfer_stock',
|
||||
'products.view_stock_mutations',
|
||||
|
||||
@ -381,7 +378,6 @@ public function run(): void
|
||||
'analysis.profit_margin',
|
||||
'analysis.top_customers',
|
||||
'analysis.top_products',
|
||||
'analysis.top_suppliers',
|
||||
'analysis.marketing_sales',
|
||||
|
||||
'employees.view',
|
||||
|
||||
@ -8,16 +8,82 @@
|
||||
public function up(): void
|
||||
{
|
||||
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void {
|
||||
// Hero Section
|
||||
$blueprint->add('hero_badge', 'Koleksi Segar 2026');
|
||||
$blueprint->add('hero_title_line1', 'Ekspresikan');
|
||||
$blueprint->add('hero_title_line2', 'Gaya');
|
||||
$blueprint->add('hero_title_highlight', 'Segar Anda');
|
||||
$blueprint->add('hero_description', 'Selamat datang di {app_name}. Temukan keanggunan motif batik modern dan setelan pakaian santai berkualitas premium. Dirancang khusus dengan bahan adem yang menyejukkan aktivitas harian Anda.');
|
||||
$blueprint->add('hero_cta_primary_text', 'Beli Sekarang');
|
||||
$blueprint->add('hero_cta_secondary_text', 'Tentang Kami');
|
||||
$blueprint->add('hero_image_url', 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80');
|
||||
$blueprint->add('about_image_url', 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80');
|
||||
$blueprint->add('gallery_images', [
|
||||
$blueprint->add('hero_bg_text_left', 'DST');
|
||||
$blueprint->add('hero_bg_text_right', 'Collection');
|
||||
|
||||
// Scroll Indicator
|
||||
$blueprint->add('scroll_hashtag', '#DSTCollection');
|
||||
$blueprint->add('scroll_tagline', 'Bahan Adem & Lembut');
|
||||
|
||||
// Catalog Section
|
||||
$blueprint->add('catalog_badge', 'Katalog Eksklusif');
|
||||
$blueprint->add('catalog_title', 'Koleksi Busana Pilihan');
|
||||
$blueprint->add('catalog_description', 'Gunakan kategori dan filter di bawah untuk menyesuaikan pencarian busana idaman Anda dengan mudah.');
|
||||
$blueprint->add('catalog_search_placeholder', 'Cari nama pakaian...');
|
||||
|
||||
// Deal Section
|
||||
$blueprint->add('deal_badge', 'Promo Terbatas');
|
||||
$blueprint->add('deal_title', 'Penawaran Bulan Ini');
|
||||
$blueprint->add('deal_description', 'Jangan lewatkan promosi batik dan daster eksklusif kami! Dapatkan potongan harga spesial up to 25% untuk varian daster pilihan dan setelan pakaian modern. Dapatkan kenyamanan ekstra dengan bahan menyejukkan sebelum masa promo habis!');
|
||||
$blueprint->add('deal_cta_text', 'Jelajahi Produk Diskon');
|
||||
$blueprint->add('deal_images', [
|
||||
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1509631179647-0177331693ae?w=800&auto=format&fit=crop&q=80',
|
||||
]);
|
||||
|
||||
// Order Guide Section
|
||||
$blueprint->add('order_guide_badge', 'Langkah Pemesanan');
|
||||
$blueprint->add('order_guide_title', 'Cara Melakukan Pemesanan');
|
||||
$blueprint->add('order_guide_description', 'Sistem pemesanan kami sangat mudah dan terhubung langsung via WhatsApp untuk pelayanan cepat dan personal.');
|
||||
$blueprint->add('order_steps', [
|
||||
[
|
||||
'title' => 'Pilih Produk',
|
||||
'description' => 'Jelajahi pakaian batik dan daster favorit Anda, lalu ketuk "Lihat Detail" untuk memeriksa ukuran.',
|
||||
],
|
||||
[
|
||||
'title' => 'Pilih Varian & Harga',
|
||||
'description' => 'Tentukan varian ukuran yang diinginkan dan pilih jenis harga (Eceran, Grosir, Agen, dll.).',
|
||||
],
|
||||
[
|
||||
'title' => 'Masukkan Keranjang',
|
||||
'description' => 'Masukkan ke Keranjang Belanja untuk menampung seluruh daftar pakaian yang ingin Anda beli.',
|
||||
],
|
||||
[
|
||||
'title' => 'Kirim ke WhatsApp',
|
||||
'description' => 'Klik tombol kirim pesanan, admin kami akan merespons rincian transfer bank dan pengiriman kurir.',
|
||||
],
|
||||
]);
|
||||
|
||||
// About Section
|
||||
$blueprint->add('about_badge', 'Tentang Kami');
|
||||
$blueprint->add('about_title', 'DST Collection');
|
||||
$blueprint->add('about_image_url', 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80');
|
||||
$blueprint->add('about_features', [
|
||||
'Kain Rayon Super Tebal & Menyerap Keringat',
|
||||
'Motif Eksklusif & Tidak Pasaran',
|
||||
'Dukungan Penuh Layanan Admin Via WhatsApp',
|
||||
]);
|
||||
|
||||
// Contact Section
|
||||
$blueprint->add('contact_badge', 'Kontak Kami');
|
||||
$blueprint->add('contact_title', 'Ada Pertanyaan? Hubungi Kami');
|
||||
$blueprint->add('contact_description', 'Kami sangat senang mendengarkan pertanyaan Anda terkait spesifikasi produk, ketersediaan grosir, atau kemitraan. Hubungi tim admin kami melalui media di bawah.');
|
||||
$blueprint->add('contact_form_title', 'Kirim Pesan Cepat');
|
||||
|
||||
// Footer
|
||||
$blueprint->add('footer_description', 'Galeri resmi {app_name}. Pilihan busana lokal premium berpotongan modern dengan kenyamanan menyejukkan.');
|
||||
$blueprint->add('footer_copyright', '© 2026 {app_name} DST Collection. Hak Cipta Dilindungi.');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -8,11 +8,25 @@
|
||||
public function up(): void
|
||||
{
|
||||
$this->migrator->inGroup('homepage', function (SettingsBlueprint $blueprint): void {
|
||||
// Delete old deal fields
|
||||
$blueprint->delete('deal_badge');
|
||||
$blueprint->delete('deal_title');
|
||||
$blueprint->delete('deal_description');
|
||||
$blueprint->delete('deal_cta_text');
|
||||
$blueprint->delete('deal_images');
|
||||
|
||||
// Add new gallery fields
|
||||
$blueprint->add('gallery_badge', 'Galeri Kami');
|
||||
$blueprint->add('gallery_title', 'Koleksi Lookbook');
|
||||
$blueprint->add('gallery_description', 'Intip koleksi lookbook kami untuk inspirasi gaya sehari-hari. Padu padan batik modern dan daster yang nyaman untuk berbagai suasana.');
|
||||
$blueprint->add('gallery_images', [
|
||||
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1572804013309-59a88b7e92f1?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1509631179647-0177331693ae?w=800&auto=format&fit=crop&q=80',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 106 KiB |
@ -1,15 +1,15 @@
|
||||
import { FlashToast, PWAUpdateToast } from '@/components/notifications';
|
||||
import { createInertiaApp } from '@inertiajs/react';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import { FlashToast } from '@/components/notifications';
|
||||
import { PWAUpdateToast } from '@/components/notifications';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { CartProvider } from '@/contexts/cart-context';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { createInertiaApp } from '@inertiajs/react';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'DST Collection';
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
registerSW({
|
||||
onNeedRefresh() {
|
||||
@ -26,8 +26,6 @@ createInertiaApp({
|
||||
switch (true) {
|
||||
case name === 'admin/manage/cutting/show':
|
||||
return null;
|
||||
case name === 'welcome':
|
||||
return null;
|
||||
case name.startsWith('auth/'):
|
||||
return AuthLayout;
|
||||
case name.startsWith('settings/'):
|
||||
@ -39,14 +37,12 @@ createInertiaApp({
|
||||
strictMode: true,
|
||||
withApp(app) {
|
||||
return (
|
||||
<CartProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<FlashToast />
|
||||
<PWAUpdateToast />
|
||||
{app}
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</CartProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<FlashToast />
|
||||
<PWAUpdateToast />
|
||||
{app}
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
progress: {
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
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} · {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>
|
||||
);
|
||||
}
|
||||
@ -1,27 +1,42 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCart } from '@/contexts/cart-context';
|
||||
import { formatRupiah } from '@/lib/rupiah';
|
||||
import { formatWhatsAppPhone } from '@/lib/format';
|
||||
import type { Product } from '@/types/homepage';
|
||||
import { ShoppingBag, ShoppingCart } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type ProductCardProps = {
|
||||
product: Product;
|
||||
onQuickView: (product: Product) => void;
|
||||
contactPhone?: string | null;
|
||||
};
|
||||
|
||||
const FALLBACK_IMAGE = 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80';
|
||||
|
||||
function getProductImage(product: Product): string {
|
||||
for (const variant of product.product_variants) {
|
||||
if (variant.photo_urls && variant.photo_urls.length > 0) {
|
||||
return variant.photo_urls[0];
|
||||
const prices = variant.product_prices;
|
||||
if (prices.length > 0) {
|
||||
return `https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80`;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@ -56,87 +71,46 @@ function getProductPriceRange(product: Product): string {
|
||||
: `Rp ${formatRupiah(min)} - Rp ${formatRupiah(max)}`;
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
export default function ProductCard({ product, onQuickView }: ProductCardProps) {
|
||||
return (
|
||||
<div className="cursor-pointer" onClick={() => onQuickView(product)}>
|
||||
<div
|
||||
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">
|
||||
<img
|
||||
src={getProductImage(product)}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover transition-transform duration-700 hover:scale-110"
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 translate-y-full group-hover:translate-y-0 transition-transform duration-300">
|
||||
<span className="text-white text-xs font-semibold uppercase tracking-widest">
|
||||
Lihat Detail
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="mt-4 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{product.categories.slice(0, 2).map((cat) => (
|
||||
<Badge key={cat.id} variant="secondary" className="bg-amber-50 text-amber-600 uppercase tracking-wider font-semibold px-2 py-0.5">
|
||||
<span
|
||||
key={cat.id}
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{cat.name}
|
||||
</Badge>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-slate-800 line-clamp-1">
|
||||
{product.name}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 line-clamp-2 font-light">
|
||||
{product.description || 'Koleksi fashion berkualitas'}
|
||||
</p>
|
||||
<p className="text-sm font-bold text-amber-600">
|
||||
{getProductPriceRange(product)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (product.product_variants.length === 1) {
|
||||
handleAddToCart();
|
||||
} else {
|
||||
onQuickView(product);
|
||||
}
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-amber-50 text-amber-600 hover:bg-amber-100 border-amber-200"
|
||||
aria-label="Tambah ke Keranjang"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button asChild className="flex-1 bg-amber-500 hover:bg-amber-600 text-white text-[11px] font-semibold uppercase tracking-wider py-2 rounded-lg transition-colors">
|
||||
<a href={contactPhone ? waUrl : '#'} target="_blank" rel="noreferrer">
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
Beli Sekarang
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,26 +1,15 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCart } from '@/contexts/cart-context';
|
||||
import { formatRupiah } from '@/lib/rupiah';
|
||||
import { formatWhatsAppPhone } from '@/lib/format';
|
||||
import type { Product, ProductVariant } from '@/types/homepage';
|
||||
import { Minus, Plus, ShoppingBag, ShoppingCart, X } from 'lucide-react';
|
||||
import { X, Star, Minus, Plus, Phone } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatRupiah } from '@/lib/rupiah';
|
||||
import type { Product, ProductVariant, ProductPrice } from '@/types/homepage';
|
||||
|
||||
type ProductModalProps = {
|
||||
product: Product | null;
|
||||
onClose: () => void;
|
||||
contactPhone?: string | null;
|
||||
};
|
||||
|
||||
const FALLBACK_IMAGE = 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80';
|
||||
|
||||
function getVariantImage(variant: ProductVariant): string {
|
||||
if (variant.photo_urls && variant.photo_urls.length > 0) {
|
||||
return variant.photo_urls[0];
|
||||
}
|
||||
return FALLBACK_IMAGE;
|
||||
function getProductImage(): string {
|
||||
return 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80';
|
||||
}
|
||||
|
||||
function getRetailPrice(variant: ProductVariant): number {
|
||||
@ -33,46 +22,29 @@ function getWholesalePrice(variant: ProductVariant): number {
|
||||
return wholesale?.price ?? 0;
|
||||
}
|
||||
|
||||
export default function ProductModal({ product, onClose, contactPhone }: ProductModalProps) {
|
||||
export default function ProductModal({ product, onClose }: ProductModalProps) {
|
||||
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
||||
const [selectedPriceType, setSelectedPriceType] = useState<'retail' | 'wholesale'>('retail');
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const { addItem } = useCart();
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
const variants = product.product_variants;
|
||||
const activeVariant = selectedVariant ?? variants[0];
|
||||
const variantImages: string[] = activeVariant?.photo_urls?.length
|
||||
? activeVariant.photo_urls
|
||||
: [FALLBACK_IMAGE];
|
||||
const currentImage = selectedImage ?? variantImages[0];
|
||||
const retailPrice = activeVariant ? getRetailPrice(activeVariant) : 0;
|
||||
const wholesalePrice = activeVariant ? getWholesalePrice(activeVariant) : 0;
|
||||
const currentPrice = selectedPriceType === 'retail' ? retailPrice : wholesalePrice;
|
||||
const maxStock = activeVariant?.stock ?? 0;
|
||||
|
||||
const handleVariantChange = (variant: ProductVariant) => {
|
||||
setSelectedVariant(variant);
|
||||
setSelectedImage(null);
|
||||
setQuantity(1);
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
const handleWhatsAppOrder = () => {
|
||||
if (!activeVariant) return;
|
||||
|
||||
addItem({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
variantId: activeVariant.id,
|
||||
variantName: activeVariant.name,
|
||||
priceType: selectedPriceType,
|
||||
price: currentPrice,
|
||||
imageUrl: getVariantImage(activeVariant),
|
||||
quantity,
|
||||
});
|
||||
toast.success(`${product.name} ditambahkan ke keranjang`);
|
||||
const priceLabel = selectedPriceType === 'retail' ? 'Retail' : 'Grosir';
|
||||
const message = `Halo, saya tertarik dengan produk:\n\n*${product.name}*\n- Varian: ${activeVariant.name}\n- Harga ${priceLabel}: Rp ${formatRupiah(currentPrice)}\n- Jumlah: ${quantity} pcs\n- Subtotal: Rp ${formatRupiah(currentPrice * quantity)}\n\nMohon info detail pesanan. Terima kasih!`;
|
||||
|
||||
const phone = '6281234567890';
|
||||
const waUrl = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
|
||||
window.open(waUrl, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
@ -87,43 +59,22 @@ export default function ProductModal({ product, onClose, contactPhone }: Product
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b border-amber-100/30">
|
||||
<h3 className="text-lg font-serif text-slate-800">Detail Produk</h3>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full hover:bg-slate-100">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
|
||||
{/* Image */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative aspect-3/4 rounded-2xl overflow-hidden">
|
||||
<img
|
||||
key={currentImage}
|
||||
src={currentImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{variantImages.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{variantImages.map((url, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(url)}
|
||||
className={`relative shrink-0 w-16 h-20 rounded-lg overflow-hidden border-2 transition-all ${currentImage === url
|
||||
? 'border-amber-500 ring-2 ring-amber-200'
|
||||
: 'border-slate-200 hover:border-amber-300 opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`${product.name} ${i + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative aspect-3/4 rounded-2xl overflow-hidden">
|
||||
<img
|
||||
src={getProductImage()}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
@ -131,9 +82,12 @@ export default function ProductModal({ product, onClose, contactPhone }: Product
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{product.categories.slice(0, 2).map((cat) => (
|
||||
<Badge key={cat.id} variant="secondary" className="bg-amber-50 text-amber-600 uppercase tracking-wider font-semibold px-2 py-0.5">
|
||||
<span
|
||||
key={cat.id}
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{cat.name}
|
||||
</Badge>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 className="text-2xl font-serif text-slate-800">{product.name}</h2>
|
||||
@ -143,12 +97,29 @@ export default function ProductModal({ product, onClose, contactPhone }: Product
|
||||
{product.description || 'Produk fashion berkualitas dengan desain elegan dan bahan nyaman.'}
|
||||
</p>
|
||||
|
||||
{/* Rating */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex text-amber-400">
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">5.0 (120+)</span>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="bg-amber-50/50 p-4 rounded-xl">
|
||||
<p className="text-xs text-slate-500 mb-1">Harga</p>
|
||||
<p className="text-2xl font-bold text-amber-600">
|
||||
Rp {formatRupiah(currentPrice)}
|
||||
</p>
|
||||
{selectedPriceType === 'wholesale' && wholesalePrice > 0 && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Harga retail: Rp {formatRupiah(retailPrice)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variant Selection */}
|
||||
@ -157,21 +128,20 @@ export default function ProductModal({ product, onClose, contactPhone }: Product
|
||||
<p className="text-xs font-semibold text-slate-700 mb-2">Varian</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{variants.map((variant) => (
|
||||
<Button
|
||||
<button
|
||||
key={variant.id}
|
||||
variant={activeVariant?.id === variant.id ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleVariantChange(variant)}
|
||||
className={activeVariant?.id === variant.id
|
||||
? 'bg-amber-600 text-white border-transparent'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
|
||||
}
|
||||
onClick={() => setSelectedVariant(variant)}
|
||||
className={`px-4 py-2 text-xs font-semibold rounded-xl border transition-all ${
|
||||
activeVariant?.id === variant.id
|
||||
? 'bg-amber-600 text-white border-transparent'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
|
||||
}`}
|
||||
>
|
||||
{variant.name}
|
||||
<span className="ml-1 text-[10px] opacity-70">
|
||||
({variant.stock})
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -181,28 +151,26 @@ export default function ProductModal({ product, onClose, contactPhone }: Product
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-700 mb-2">Jenis Harga</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={selectedPriceType === 'retail' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
<button
|
||||
onClick={() => setSelectedPriceType('retail')}
|
||||
className={selectedPriceType === 'retail'
|
||||
? 'bg-amber-600 text-white border-transparent'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
|
||||
}
|
||||
className={`px-4 py-2 text-xs font-semibold rounded-xl border transition-all ${
|
||||
selectedPriceType === 'retail'
|
||||
? 'bg-amber-600 text-white border-transparent'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
|
||||
}`}
|
||||
>
|
||||
Retail
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedPriceType === 'wholesale' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedPriceType('wholesale')}
|
||||
className={selectedPriceType === 'wholesale'
|
||||
? 'bg-amber-600 text-white border-transparent'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
|
||||
}
|
||||
className={`px-4 py-2 text-xs font-semibold rounded-xl border transition-all ${
|
||||
selectedPriceType === 'wholesale'
|
||||
? 'bg-amber-600 text-white border-transparent'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:border-amber-300'
|
||||
}`}
|
||||
>
|
||||
Grosir
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -210,21 +178,19 @@ export default function ProductModal({ product, onClose, contactPhone }: Product
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-700 mb-2">Jumlah</p>
|
||||
<div className="flex items-center gap-3 border border-slate-200 rounded-xl px-3 py-2 w-fit">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="p-1 text-slate-500 hover:text-slate-800"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</Button>
|
||||
</button>
|
||||
<span className="text-sm font-semibold w-8 text-center">{quantity}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
<button
|
||||
onClick={() => setQuantity(Math.min(maxStock, quantity + 1))}
|
||||
className="p-1 text-slate-500 hover:text-slate-800"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Stok tersedia: {maxStock}</p>
|
||||
</div>
|
||||
@ -239,19 +205,14 @@ export default function ProductModal({ product, onClose, contactPhone }: Product
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleAddToCart} variant="outline" className="flex-1 border-amber-200 text-amber-700 hover:bg-amber-50 py-3.5 text-xs font-bold uppercase tracking-widest rounded-xl transition-all">
|
||||
<ShoppingBag className="w-4 h-4 mr-2" />
|
||||
Keranjang
|
||||
</Button>
|
||||
<Button asChild className="flex-1 bg-amber-600 text-white hover:bg-amber-500 py-3.5 text-xs font-bold uppercase tracking-widest rounded-xl transition-all shadow-md">
|
||||
<a href={contactPhone ? `https://wa.me/${formatWhatsAppPhone(contactPhone)}?text=${encodeURIComponent(`Halo min, saya mau order, berikut pesanannya.\n\n*${product.name}*\n* Varian: ${activeVariant?.name ?? '-'}\n* Harga: Rp ${formatRupiah(currentPrice)}\n* QTY : ${quantity}\n* Subtotal : Rp ${formatRupiah(currentPrice * quantity)}\n\nTotal: Rp ${formatRupiah(currentPrice * quantity)}\n\nMohon diinformasikan detail pembayaran dan proses selanjutnya ya, Kak.\n\nTerimakasih`)}` : '#'} target="_blank" rel="noreferrer">
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
Beli Sekarang
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
{/* WhatsApp Order */}
|
||||
<button
|
||||
onClick={handleWhatsAppOrder}
|
||||
className="w-full flex items-center justify-center bg-emerald-600 text-white hover:bg-emerald-500 py-3.5 text-xs font-bold uppercase tracking-widest rounded-xl transition-all shadow-md"
|
||||
>
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
Pesan Via WhatsApp
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
|
||||
import { AppLogo, AppLogoIcon } from '@/components/brand';
|
||||
import { Breadcrumbs } from '@/components/layout';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
@ -20,14 +22,17 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { UserMenuContent } from '@/components/user';
|
||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, toUrl } from '@/lib/utils';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { LayoutGrid, Menu } from 'lucide-react';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
@ -41,6 +46,19 @@ const mainNavItems: NavItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const rightNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/react-starter-kit',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#react',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
|
||||
const activeItemStyles =
|
||||
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
|
||||
|
||||
@ -92,6 +110,23 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4">
|
||||
{rightNavItems.map((item) => (
|
||||
<a
|
||||
key={item.title}
|
||||
href={toUrl(item.href)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 font-medium"
|
||||
>
|
||||
{item.icon && (
|
||||
<item.icon className="h-5 w-5" />
|
||||
)}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
@ -141,7 +176,40 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
<DropdownMenu>k
|
||||
<div className="relative flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group h-9 w-9 cursor-pointer"
|
||||
>
|
||||
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
|
||||
</Button>
|
||||
<div className="ml-1 hidden gap-1 lg:flex">
|
||||
{rightNavItems.map((item) => (
|
||||
<Tooltip key={item.title}>
|
||||
<TooltipTrigger>
|
||||
<a
|
||||
href={toUrl(item.href)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium text-accent-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.icon && (
|
||||
<item.icon className="size-5 opacity-80 group-hover:opacity-100" />
|
||||
)}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -1,102 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -5,20 +5,6 @@ export function formatNumber(
|
||||
return new Intl.NumberFormat('id-ID', options).format(num);
|
||||
}
|
||||
|
||||
export function formatWhatsAppPhone(phone: string): string {
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
|
||||
if (digits.startsWith('62')) {
|
||||
return digits;
|
||||
}
|
||||
|
||||
if (digits.startsWith('0')) {
|
||||
return '62' + digits.slice(1);
|
||||
}
|
||||
|
||||
return '62' + digits;
|
||||
}
|
||||
|
||||
export type DateFormatStyle = 'full' | 'short' | 'datetime';
|
||||
|
||||
export function formatDate(dateString: string, style: DateFormatStyle = 'full'): string {
|
||||
|
||||
@ -515,7 +515,7 @@ export default function Analysis({
|
||||
}
|
||||
|
||||
if (hasAnyRole(['admin_toko', 'direktur'])) {
|
||||
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topSuppliers: 11, topProducts: 12, topCustomers: 13, busyHours: 14 };
|
||||
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topProducts: 11, topCustomers: 12, busyHours: 13 };
|
||||
}
|
||||
|
||||
if (hasRole('marketing')) {
|
||||
|
||||
@ -28,7 +28,7 @@ export type EmployeeAdvance = {
|
||||
description: string;
|
||||
due_date: string;
|
||||
formatted_due_date: string;
|
||||
status: 'pending' | 'disbursed' | 'partial' | 'repaid' | 'rejected';
|
||||
status: 'pending' | 'approved' | 'paid' | 'rejected' | 'cancelled';
|
||||
created_at: string;
|
||||
formatted_created_at: string;
|
||||
employee: {
|
||||
@ -48,22 +48,22 @@ function getStatusBadge(status: string) {
|
||||
label: 'Menunggu',
|
||||
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100',
|
||||
},
|
||||
disbursed: {
|
||||
label: 'Dikeluarkan',
|
||||
approved: {
|
||||
label: 'Disetujui',
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-100',
|
||||
},
|
||||
partial: {
|
||||
label: 'Cicilan',
|
||||
className: 'bg-orange-100 text-orange-800 hover:bg-orange-100',
|
||||
},
|
||||
repaid: {
|
||||
label: 'Lunas',
|
||||
paid: {
|
||||
label: 'Dibayar',
|
||||
className: 'bg-blue-100 text-blue-800 hover:bg-blue-100',
|
||||
},
|
||||
rejected: {
|
||||
label: 'Ditolak',
|
||||
className: 'bg-red-100 text-red-800 hover:bg-red-100',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Dibatalkan',
|
||||
className: 'bg-gray-100 text-gray-800 hover:bg-gray-100',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status] ?? statusConfig.pending;
|
||||
@ -127,7 +127,7 @@ export function createEmployeeAdvanceColumns(
|
||||
cell: ({ row }) => {
|
||||
const employeeAdvance = row.original;
|
||||
|
||||
if (employeeAdvance.status === 'repaid') {
|
||||
if (employeeAdvance.status === 'paid') {
|
||||
return <span className="text-green-600">Lunas</span>;
|
||||
}
|
||||
|
||||
@ -202,7 +202,7 @@ export function createEmployeeAdvanceColumns(
|
||||
),
|
||||
show:
|
||||
can('employee_advances.pay') &&
|
||||
(employeeAdvance.status === 'disbursed' || employeeAdvance.status === 'partial'),
|
||||
employeeAdvance.status === 'approved',
|
||||
onClick: () => handlePay(employeeAdvance),
|
||||
},
|
||||
{
|
||||
|
||||
@ -32,7 +32,6 @@ export type Product = {
|
||||
slug: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
is_featured: boolean;
|
||||
rejection_reason: string | null;
|
||||
categories: {
|
||||
id: number;
|
||||
|
||||
@ -32,7 +32,6 @@ import {
|
||||
edit as productEdit,
|
||||
index as productIndex,
|
||||
toggleStatus,
|
||||
toggleFeatured as productToggleFeatured,
|
||||
approve as productApprove,
|
||||
reject as productReject,
|
||||
resubmit as productResubmit,
|
||||
@ -64,7 +63,6 @@ type Props = {
|
||||
name?: string;
|
||||
stock?: string;
|
||||
category?: string;
|
||||
featured?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -162,8 +160,7 @@ export default function ProductIndex({ products, categories, productNames, filte
|
||||
filters.status ||
|
||||
filters.name ||
|
||||
filters.stock ||
|
||||
filters.category ||
|
||||
filters.featured,
|
||||
filters.category,
|
||||
)}
|
||||
onClear={clearFilters}
|
||||
>
|
||||
@ -268,25 +265,6 @@ export default function ProductIndex({ products, categories, productNames, filte
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Halaman Depan
|
||||
</label>
|
||||
<Select
|
||||
value={filters.featured ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('featured', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua</SelectItem>
|
||||
<SelectItem value="true">Ditampilkan</SelectItem>
|
||||
<SelectItem value="false">Tidak Ditampilkan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FilterPopover>
|
||||
);
|
||||
|
||||
@ -343,7 +321,6 @@ export default function ProductIndex({ products, categories, productNames, filte
|
||||
onDelete={(p) => setDeleting(p)}
|
||||
onReject={(p) => setRejecting(p)}
|
||||
toggleStatusUrl={(id) => toggleStatus.url(id)}
|
||||
toggleFeaturedUrl={(id) => productToggleFeatured.url(id)}
|
||||
approveUrl={(id) => productApprove.url(id)}
|
||||
resubmitUrl={(id) => productResubmit.url(id)}
|
||||
/>
|
||||
|
||||
@ -42,7 +42,6 @@ export type ProductCardRowParams = {
|
||||
onDelete: (product: Product) => void;
|
||||
onReject: (product: Product) => void;
|
||||
toggleStatusUrl: (id: number) => string;
|
||||
toggleFeaturedUrl: (id: number) => string;
|
||||
approveUrl: (id: number) => string;
|
||||
resubmitUrl: (id: number) => string;
|
||||
};
|
||||
@ -56,7 +55,6 @@ export function ProductCardRow({
|
||||
onDelete,
|
||||
onReject,
|
||||
toggleStatusUrl,
|
||||
toggleFeaturedUrl,
|
||||
approveUrl,
|
||||
resubmitUrl,
|
||||
}: ProductCardRowParams) {
|
||||
@ -175,16 +173,6 @@ export function ProductCardRow({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(product.status === 'active' || product.status === 'inactive') && (
|
||||
<div className="mt-2">
|
||||
<ToggleStatus
|
||||
url={toggleFeaturedUrl(product.id)}
|
||||
checked={product.is_featured}
|
||||
label={product.is_featured ? 'Ditampilkan di Halaman Depan' : 'Tidak Ditampilkan'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@ -1,28 +1,23 @@
|
||||
import ProductCard from '@/components/home/ProductCard';
|
||||
import CartDrawer from '@/components/home/CartDrawer';
|
||||
import ProductModal from '@/components/home/ProductModal';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useCart } from '@/contexts/cart-context';
|
||||
import { formatRupiah } from '@/lib/rupiah';
|
||||
import type { Category, HomepageData, Paginated, Product, ProductPrice } from '@/types/homepage';
|
||||
import { Head, InfiniteScroll, Link, router } from '@inertiajs/react';
|
||||
import { Head, Link, router, InfiniteScroll } from '@inertiajs/react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Info,
|
||||
LogIn,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Plus,
|
||||
Search,
|
||||
ShoppingBag,
|
||||
Plus,
|
||||
ArrowRight,
|
||||
Star,
|
||||
Info,
|
||||
ChevronRight,
|
||||
Phone,
|
||||
MapPin,
|
||||
Mail,
|
||||
LogIn,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { formatRupiah } from '@/lib/rupiah';
|
||||
import ProductCard from '@/components/home/ProductCard';
|
||||
import ProductModal from '@/components/home/ProductModal';
|
||||
import type { Product, Category, HomepageData, ProductPrice, Paginated } from '@/types/homepage';
|
||||
|
||||
type Props = {
|
||||
appName: string;
|
||||
@ -57,43 +52,30 @@ export default function Welcome({
|
||||
const [searchInput, setSearchInput] = useState(filters.search);
|
||||
const [selectedCategory, setSelectedCategory] = useState(filters.category);
|
||||
const [activeProduct, setActiveProduct] = useState<Product | null>(null);
|
||||
const [activeSection, setActiveSection] = useState('hero');
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const { totalItems } = useCart();
|
||||
|
||||
useEffect(() => {
|
||||
const sections = ['hero', 'catalog', 'gallery', 'order-guide', 'about', 'contact'];
|
||||
const observers: IntersectionObserver[] = [];
|
||||
|
||||
sections.forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-40% 0px -55% 0px' },
|
||||
);
|
||||
observer.observe(el);
|
||||
observers.push(observer);
|
||||
});
|
||||
|
||||
return () => observers.forEach((o) => o.disconnect());
|
||||
}, []);
|
||||
|
||||
const FALLBACK_IMAGE = 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80';
|
||||
|
||||
const getProductImage = (product: Product): string => {
|
||||
for (const variant of product.product_variants) {
|
||||
if (variant.photo_urls && variant.photo_urls.length > 0) {
|
||||
return variant.photo_urls[0];
|
||||
}
|
||||
}
|
||||
return FALLBACK_IMAGE;
|
||||
const fallbacks: Record<string, string[]> = {
|
||||
daster: [
|
||||
'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=800&auto=format&fit=crop&q=80',
|
||||
],
|
||||
'setelan-celana': [
|
||||
'https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=800&auto=format&fit=crop&q=80',
|
||||
],
|
||||
atasan: [
|
||||
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
|
||||
],
|
||||
bawahan: [
|
||||
'https://images.unsplash.com/photo-1583496661160-fb4886b36ca7?w=800&auto=format&fit=crop&q=80',
|
||||
],
|
||||
};
|
||||
|
||||
const firstCat = product.categories?.[0]?.slug || '';
|
||||
const urls = fallbacks[firstCat] || [
|
||||
'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80',
|
||||
];
|
||||
|
||||
return urls[0];
|
||||
};
|
||||
|
||||
const getProductPriceRange = (product: Product): string => {
|
||||
@ -163,6 +145,7 @@ export default function Welcome({
|
||||
fetchProducts('', '');
|
||||
}, [fetchProducts]);
|
||||
|
||||
// Use first 3 products from current page as featured for hero
|
||||
const featuredProducts = products.data.slice(0, 3);
|
||||
|
||||
return (
|
||||
@ -176,42 +159,30 @@ export default function Welcome({
|
||||
{/* Sticky Header */}
|
||||
<header className="sticky top-0 z-40 bg-white/70 backdrop-blur-lg border-b border-amber-100/50 transition-all">
|
||||
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center">
|
||||
<img src="/assets/logo.png" alt={appName} className="h-12 w-auto" />
|
||||
</Link>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<span className="font-serif text-2xl font-bold tracking-widest text-amber-600">
|
||||
{appName}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="hidden lg:flex items-center space-x-8 text-xs font-semibold uppercase tracking-widest text-slate-600">
|
||||
{[
|
||||
{ id: 'hero', label: 'Beranda' },
|
||||
{ id: 'catalog', label: 'Produk' },
|
||||
{ id: 'about', label: 'Tentang Kami' },
|
||||
{ id: 'order-guide', label: 'Cara Pesan' },
|
||||
{ id: 'contact', label: 'Hubungi Kami' },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
className={`transition-colors ${activeSection === item.id ? 'text-amber-600' : 'hover:text-amber-600'}`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
<a href="#" className="hover:text-amber-600 transition-colors">Beranda</a>
|
||||
<a href="#catalog" className="hover:text-amber-600 transition-colors">Produk</a>
|
||||
<a href="#about" className="hover:text-amber-600 transition-colors">Tentang Kami</a>
|
||||
<a href="#order-guide" className="hover:text-amber-600 transition-colors">Cara Pesan</a>
|
||||
<a href="#contact" className="hover:text-amber-600 transition-colors">Hubungi Kami</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => setCartOpen(true)} className="relative">
|
||||
<ShoppingBag className="w-4.5 h-4.5 text-amber-600" />
|
||||
{totalItems > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4.5 h-4.5 bg-amber-600 text-white text-[9px] font-bold rounded-full flex items-center justify-center">
|
||||
{totalItems}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a href="/admin/dashboard" aria-label="Login">
|
||||
<LogIn className="w-4.5 h-4.5 text-amber-600" />
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex items-center space-x-4">
|
||||
<a
|
||||
href="/admin/dashboard"
|
||||
className="p-2 rounded-full hover:bg-amber-50 text-slate-600 transition-colors"
|
||||
aria-label="Login"
|
||||
>
|
||||
<LogIn className="w-4.5 h-4.5 text-amber-600" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@ -226,49 +197,53 @@ export default function Welcome({
|
||||
<div className="max-w-7xl mx-auto px-6 w-full lg:grow grid grid-cols-1 lg:grid-cols-12 gap-8 items-center py-12 z-10">
|
||||
{/* Left Column */}
|
||||
<div className="lg:col-span-5 flex flex-col justify-center space-y-6 lg:pr-6">
|
||||
<Badge variant="secondary" className="w-fit bg-amber-500/10 text-amber-700 uppercase tracking-widest font-bold px-3 py-1 shadow-sm">
|
||||
Hadir dengan Gaya Terbaru
|
||||
</Badge>
|
||||
<div className="inline-flex items-center space-x-2 bg-amber-500/10/10 text-[10px] text-amber-700 uppercase tracking-widest px-3 py-1 rounded-full w-fit font-bold shadow-sm">
|
||||
<span>{homepage.hero_badge}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-6xl font-serif leading-[1.1] tracking-tight font-light select-none text-slate-800">
|
||||
Nyaman<br />
|
||||
dalam setiap{' '}
|
||||
{homepage.hero_title_line1} <br />
|
||||
{homepage.hero_title_line2}{' '}
|
||||
<span className="font-normal italic text-amber-600 bg-amber-100/20 px-2 rounded-lg">
|
||||
Penampilan
|
||||
{homepage.hero_title_highlight}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-sm text-slate-500 max-w-md leading-relaxed font-light">
|
||||
Koleksi fashion wanita dengan sentuhan elegan, material berkualitas, dan kenyamanan yang dapat Anda rasakan di setiap pemakaian.
|
||||
{homepage.hero_description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-4 pt-4">
|
||||
<Button asChild className="bg-amber-600 text-white px-8 py-3.5 text-xs font-semibold uppercase tracking-widest rounded-xl shadow-lg shadow-amber-200/50 hover:bg-amber-500 hover:-translate-y-0.5 transition-all duration-300 group">
|
||||
<a href="#catalog">
|
||||
Beli Sekarang
|
||||
<ArrowRight className="w-3.5 h-3.5 ml-2 transform group-hover:translate-x-1 transition-transform" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="border-amber-200 text-xs font-semibold uppercase tracking-widest px-8 py-3.5 rounded-xl hover:bg-amber-500/5 hover:border-amber-400 transition-all">
|
||||
<a href="#about">Tentang Kami</a>
|
||||
</Button>
|
||||
<a
|
||||
href="#catalog"
|
||||
className="inline-flex items-center justify-center bg-amber-600 text-white px-8 py-3.5 text-xs font-semibold uppercase tracking-widest rounded-xl shadow-lg shadow-amber-200/50 hover:bg-amber-500 hover:-translate-y-0.5 transition-all duration-300 group"
|
||||
>
|
||||
{homepage.hero_cta_primary_text}
|
||||
<ArrowRight className="w-3.5 h-3.5 ml-2 transform group-hover:translate-x-1 transition-transform" />
|
||||
</a>
|
||||
<a
|
||||
href="#about"
|
||||
className="inline-flex items-center justify-center border border-amber-200 text-xs font-semibold uppercase tracking-widest px-8 py-3.5 rounded-xl hover:bg-amber-500/5 hover:border-amber-400 transition-all"
|
||||
>
|
||||
{homepage.hero_cta_secondary_text}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Column: Visual */}
|
||||
<div className="lg:col-span-7 relative flex justify-center items-center h-[500px] lg:h-[650px] w-full">
|
||||
<div className="absolute inset-0 flex justify-center items-center pointer-events-none select-none z-0">
|
||||
<div className="text-[14vw] lg:text-[120px] font-serif font-bold leading-none tracking-tight flex opacity-[0.03]">
|
||||
<span className="text-slate-900">{homepage.hero_bg_text_left}</span>
|
||||
<span className="text-amber-600">{homepage.hero_bg_text_right}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 -z-10 flex justify-center items-center pointer-events-none select-none">
|
||||
<div className="absolute w-[350px] h-[350px] rounded-full bg-gradient-to-tr from-amber-400/25 via-yellow-400/20 to-orange-300/20 blur-3xl" />
|
||||
<div className="absolute w-[400px] h-[400px] rounded-full bg-gradient-to-br from-rose-300/20 via-amber-200/20 to-orange-300/20 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex justify-center items-center pointer-events-none select-none z-0">
|
||||
<div className="text-[14vw] lg:text-[120px] font-serif font-bold leading-none tracking-tight flex text-amber-200">
|
||||
<span className="text-amber-600">DST</span>
|
||||
<span className="text-slate-900">Collection</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-[310px] h-[440px] lg:w-[390px] lg:h-[540px] rounded-[2rem] overflow-hidden z-10 transition-transform duration-500 hover:scale-[1.02]">
|
||||
<img
|
||||
src={homepage.hero_image_url || 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1000&auto=format&fit=crop&q=80'}
|
||||
@ -327,20 +302,14 @@ export default function Welcome({
|
||||
{/* Scroll Indicator */}
|
||||
<div className="max-w-7xl mx-auto px-6 w-full flex items-center justify-between pb-8 z-10 text-xs font-semibold text-slate-500">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-amber-600">#DSTCollection</span>
|
||||
<span className="text-amber-600">#{homepage.scroll_hashtag}</span>
|
||||
<span className="w-1 h-1 rounded-full bg-amber-300" />
|
||||
<span>Bahan Adem & Lembut</span>
|
||||
<span>{homepage.scroll_tagline}</span>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<a href="#catalog" className="flex items-center space-x-2 hover:text-amber-600 transition-colors">
|
||||
<span>Lihat katalog lengkap</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 rotate-90 text-amber-600" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -348,51 +317,47 @@ export default function Welcome({
|
||||
<section id="catalog" className="max-w-7xl mx-auto px-6 py-20 border-t border-amber-100/30">
|
||||
<div className="space-y-6 mb-12">
|
||||
<div className="text-center space-y-2">
|
||||
<Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
|
||||
Katalog Eksklusif
|
||||
</Badge>
|
||||
<h3 className="text-3xl font-serif text-slate-800">Koleksi Busana Pilihan</h3>
|
||||
<p className="text-sm text-slate-500 max-w-lg mx-auto font-light">Gunakan kategori dan filter di bawah untuk menyesuaikan pencarian busana idaman Anda dengan mudah.</p>
|
||||
<h2 className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.catalog_badge}</h2>
|
||||
<h3 className="text-3xl font-serif text-slate-800">{homepage.catalog_title}</h3>
|
||||
<p className="text-sm text-slate-500 max-w-lg mx-auto font-light">{homepage.catalog_description}</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white/80 backdrop-blur-md p-6 rounded-3xl shadow-sm border border-amber-100/30 flex flex-col md:flex-row items-center gap-4 justify-between">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Input
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Cari produk..."
|
||||
className="w-full bg-slate-50 border-slate-200 text-xs px-4 py-3 pr-10 rounded-xl focus:border-amber-500 text-slate-700 transition-all"
|
||||
placeholder={homepage.catalog_search_placeholder}
|
||||
className="w-full bg-slate-50 border border-slate-200 text-xs px-4 py-3 pr-10 rounded-xl outline-none focus:border-amber-500 text-slate-700 transition-all"
|
||||
/>
|
||||
<Search className="absolute right-3 top-3.5 w-4 h-4 text-slate-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
variant={!selectedCategory ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
<button
|
||||
onClick={() => handleCategoryChange(null)}
|
||||
className={`shrink-0 text-[11px] font-semibold uppercase tracking-wider rounded-xl transition-all whitespace-nowrap ${!selectedCategory
|
||||
? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50'
|
||||
: 'bg-transparent text-slate-600 border-slate-200 hover'
|
||||
}`}
|
||||
>
|
||||
Semua
|
||||
</Button>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category.id}
|
||||
variant={selectedCategory === category.slug ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleCategoryChange(selectedCategory === category.slug ? null : category.slug)}
|
||||
className={`shrink-0 text-[11px] font-semibold uppercase tracking-wider rounded-xl transition-all whitespace-nowrap ${selectedCategory === category.slug
|
||||
className={`shrink-0 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider rounded-xl border transition-all whitespace-nowrap ${
|
||||
!selectedCategory
|
||||
? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50'
|
||||
: 'bg-transparent text-slate-600 border-slate-200 hover'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
Semua
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => handleCategoryChange(selectedCategory === category.slug ? null : category.slug)}
|
||||
className={`shrink-0 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider rounded-xl border transition-all whitespace-nowrap ${
|
||||
selectedCategory === category.slug
|
||||
? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/50'
|
||||
: 'bg-transparent text-slate-600 border-slate-200 hover'
|
||||
}`}
|
||||
>
|
||||
{category.name}
|
||||
</Button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -419,7 +384,6 @@ export default function Welcome({
|
||||
key={product.id}
|
||||
product={product}
|
||||
onQuickView={setActiveProduct}
|
||||
contactPhone={contactPhone}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -430,9 +394,12 @@ export default function Welcome({
|
||||
<p className="text-sm text-slate-400">
|
||||
Mohon maaf, kami tidak menemukan pakaian yang cocok dengan kata kunci pencarian Anda.
|
||||
</p>
|
||||
<Button variant="ghost" onClick={handleResetFilters} className="mt-2 text-xs font-bold uppercase tracking-widest text-amber-600 underline underline-offset-4">
|
||||
<button
|
||||
onClick={handleResetFilters}
|
||||
className="mt-2 text-xs font-bold uppercase tracking-widest text-amber-600 underline underline-offset-4"
|
||||
>
|
||||
Reset Pencarian
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
@ -442,11 +409,9 @@ export default function Welcome({
|
||||
<section id="gallery" className="w-full bg-gradient-to-br from-amber-50/40 via-yellow-50/20 to-orange-50/30 py-24 border-t border-b border-amber-100/30">
|
||||
<div className="max-w-7xl mx-auto px-6 space-y-12">
|
||||
<div className="text-center space-y-3 max-w-lg mx-auto">
|
||||
<Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
|
||||
Galeri Kami
|
||||
</Badge>
|
||||
<h3 className="text-4xl font-serif leading-tight">Koleksi Lookbook</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed font-light">Intip koleksi lookbook kami untuk inspirasi gaya sehari-hari. Padu padan pakaian modern yang nyaman untuk berbagai suasana.</p>
|
||||
<span className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.gallery_badge}</span>
|
||||
<h3 className="text-4xl font-serif leading-tight">{homepage.gallery_title}</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed font-light">{homepage.gallery_description}</p>
|
||||
</div>
|
||||
|
||||
{homepage.gallery_images.length > 0 ? (
|
||||
@ -476,20 +441,13 @@ export default function Welcome({
|
||||
{/* Order Guide Section */}
|
||||
<section id="order-guide" className="max-w-7xl mx-auto px-6 py-20 border-b border-amber-100/30">
|
||||
<div className="text-center space-y-3 mb-16">
|
||||
<Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
|
||||
Langkah Pemesanan
|
||||
</Badge>
|
||||
<h4 className="text-3xl font-serif">Cara Melakukan Pemesanan</h4>
|
||||
<p className="text-sm text-slate-500 max-w-md mx-auto font-light">Sistem pemesanan kami sangat mudah dan terhubung langsung via WhatsApp untuk pelayanan cepat dan personal.</p>
|
||||
<h3 className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.order_guide_badge}</h3>
|
||||
<h4 className="text-3xl font-serif">{homepage.order_guide_title}</h4>
|
||||
<p className="text-sm text-slate-500 max-w-md mx-auto font-light">{homepage.order_guide_description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{[
|
||||
{ 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) => (
|
||||
{homepage.order_steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white/50 p-6 rounded-2xl border border-amber-100/30 shadow-sm relative overflow-hidden text-center group hover:-translate-y-1 transition-transform"
|
||||
@ -521,14 +479,12 @@ export default function Welcome({
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-6 space-y-6">
|
||||
<Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1">
|
||||
Tentang Kami
|
||||
</Badge>
|
||||
<h3 className="text-3xl font-serif">DST Collection</h3>
|
||||
<span className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.about_badge}</span>
|
||||
<h3 className="text-3xl font-serif">{homepage.about_title}</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed font-light whitespace-pre-line">
|
||||
{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.'}
|
||||
{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.'}
|
||||
</p>
|
||||
{['Kain Rayon Super Tebal & Menyerap Keringat', 'Motif Eksklusif & Tidak Pasaran', 'Dukungan Penuh Layanan Admin Via WhatsApp'].map((feature, index) => (
|
||||
{homepage.about_features.map((feature, index) => (
|
||||
<div key={index} className="flex items-center space-x-3 text-xs text-slate-500 font-semibold">
|
||||
<Check className="w-4 h-4 text-emerald-500" />
|
||||
<span>{feature}</span>
|
||||
@ -540,15 +496,13 @@ export default function Welcome({
|
||||
|
||||
{/* Contact Section */}
|
||||
<section id="contact" className="max-w-7xl mx-auto px-6 py-20">
|
||||
<div className="bg-white/60 backdrop-blur-md border border-amber-100/30 rounded-3xl overflow-hidden shadow-sm grid grid-cols-1 lg:grid-cols-2">
|
||||
<div className="p-8 space-y-6 flex flex-col justify-center">
|
||||
<Badge variant="secondary" className="bg-amber-500/10 text-amber-700 uppercase tracking-[0.3em] font-bold px-3 py-1 w-fit">
|
||||
Kontak Kami
|
||||
</Badge>
|
||||
<h3 className="text-3xl font-serif">Ada Pertanyaan? Hubungi Kami</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed font-light">Kami sangat senang mendengarkan pertanyaan Anda terkait spesifikasi produk, ketersediaan grosir, atau kemitraan. Hubungi tim admin kami melalui media di bawah.</p>
|
||||
<div className="bg-white/60 backdrop-blur-md border border-amber-100/30 p-8 rounded-3xl grid grid-cols-1 lg:grid-cols-2 gap-12 items-center shadow-sm">
|
||||
<div className="space-y-6">
|
||||
<span className="text-xs uppercase tracking-[0.3em] font-bold text-amber-600">{homepage.contact_badge}</span>
|
||||
<h3 className="text-3xl font-serif">{homepage.contact_title}</h3>
|
||||
<p className="text-sm text-slate-500 leading-relaxed font-light">{homepage.contact_description}</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{contactPhone && (
|
||||
<div className="flex items-center space-x-3 text-sm">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-100/40 text-amber-600 flex items-center justify-center">
|
||||
@ -575,17 +529,30 @@ export default function Welcome({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-80 lg:h-auto">
|
||||
<iframe
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d304.913429094586!2d107.58352379265978!3d-6.414270414938328!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x2e696bd874fdab6d%3A0x12de5546c3d8eac4!2sAGEN%20JNE%20DEESTEPABUARAN!5e1!3m2!1sen!2sid!4v1786611903455!5m2!1sen!2sid"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="w-full h-full min-h-[320px]"
|
||||
/>
|
||||
|
||||
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 space-y-4">
|
||||
<h4 className="font-bold text-base">{homepage.contact_form_title}</h4>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nama Anda"
|
||||
className="w-full text-xs px-4 py-3 bg-white border border-slate-200 rounded-lg outline-none focus:border-amber-500 text-slate-700"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Tulis pesan Anda di sini..."
|
||||
rows={4}
|
||||
className="w-full text-xs px-4 py-3 bg-white border border-slate-200 rounded-lg outline-none focus:border-amber-500 text-slate-700"
|
||||
/>
|
||||
<a
|
||||
href={`https://wa.me/${(contactPhone || '6281234567890').replace(/[^0-9]/g, '')}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-500 text-white py-3 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors"
|
||||
>
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
Kirim Melalui WhatsApp
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -594,9 +561,13 @@ export default function Welcome({
|
||||
<footer className="bg-white text-slate-600 py-16 border-t border-amber-100/30">
|
||||
<div className="max-w-7xl mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div className="space-y-4">
|
||||
<img src="/assets/logo.png" alt={appName} className="h-10 w-auto" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<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">
|
||||
Galeri resmi {appName}. Pilihan busana lokal premium berpotongan modern dengan kenyamanan menyejukkan.
|
||||
{homepage.footer_description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -639,13 +610,12 @@ export default function Welcome({
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 mt-12 pt-8 border-t border-amber-100/30 flex flex-col sm:flex-row justify-between items-center text-[10px] text-slate-400 font-light space-y-4 sm:space-y-0">
|
||||
<p>© {new Date().getFullYear()} {appName}. Hak Cipta Dilindungi.</p>
|
||||
<p>© {new Date().getFullYear()} {appName}. {homepage.footer_copyright}</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Product Modal */}
|
||||
<ProductModal product={activeProduct} onClose={() => setActiveProduct(null)} contactPhone={contactPhone} />
|
||||
<CartDrawer open={cartOpen} onClose={() => setCartOpen(false)} contactPhone={contactPhone} />
|
||||
<ProductModal product={activeProduct} onClose={() => setActiveProduct(null)} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -14,7 +14,6 @@ export type ProductVariant = {
|
||||
stock: number;
|
||||
formatted_stock: string;
|
||||
product_prices: ProductPrice[];
|
||||
photo_urls: string[];
|
||||
};
|
||||
|
||||
export type ProductCategory = {
|
||||
@ -43,9 +42,40 @@ export type Category = {
|
||||
};
|
||||
|
||||
export type HomepageData = {
|
||||
hero_badge: string;
|
||||
hero_title_line1: string;
|
||||
hero_title_line2: string;
|
||||
hero_title_highlight: string;
|
||||
hero_description: string;
|
||||
hero_cta_primary_text: string;
|
||||
hero_cta_secondary_text: string;
|
||||
hero_image_url: string | null;
|
||||
about_image_url: string | null;
|
||||
hero_bg_text_left: string;
|
||||
hero_bg_text_right: string;
|
||||
scroll_hashtag: string;
|
||||
scroll_tagline: string;
|
||||
catalog_badge: string;
|
||||
catalog_title: string;
|
||||
catalog_description: string;
|
||||
catalog_search_placeholder: string;
|
||||
gallery_badge: string;
|
||||
gallery_title: string;
|
||||
gallery_description: string;
|
||||
gallery_images: string[];
|
||||
order_guide_badge: string;
|
||||
order_guide_title: string;
|
||||
order_guide_description: string;
|
||||
order_steps: Array<{ title: string; description: string }>;
|
||||
about_badge: string;
|
||||
about_title: string;
|
||||
about_image_url: string | null;
|
||||
about_features: string[];
|
||||
contact_badge: string;
|
||||
contact_title: string;
|
||||
contact_description: string;
|
||||
contact_form_title: string;
|
||||
footer_description: string;
|
||||
footer_copyright: string;
|
||||
};
|
||||
|
||||
export type Paginated<T> = {
|
||||
|
||||
@ -41,7 +41,6 @@
|
||||
|
||||
Route::resource('products', ProductController::class)->except(['show'])->middleware('permission:products.view|products.create|products.update|products.delete');
|
||||
Route::post('products/{product}/toggle-status', [ProductController::class, 'toggleStatus'])->name('products.toggle-status')->middleware('permission:products.toggle_status');
|
||||
Route::post('products/{product}/toggle-featured', [ProductController::class, 'toggleFeatured'])->name('products.toggle-featured')->middleware('permission:products.toggle_featured');
|
||||
Route::post('products/{product}/approve', [ProductController::class, 'approve'])->name('products.approve')->middleware('permission:products.update');
|
||||
Route::post('products/{product}/reject', [ProductController::class, 'reject'])->name('products.reject')->middleware('permission:products.update');
|
||||
Route::post('products/{product}/resubmit', [ProductController::class, 'resubmit'])->name('products.resubmit')->middleware('permission:products.update');
|
||||
|
||||
@ -522,7 +522,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($kasbonUser, WebPushNotification::class);
|
||||
|
||||
expect($kasbonUser->notifications()->where('title', 'Kasbon Dikeluarkan')->count())->toBe(1);
|
||||
expect($kasbonUser->notifications()->where('title', 'Kasbon Disetujui')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('EmployeeAdvanceService pay sends notification including the employee', function () {
|
||||
@ -555,7 +555,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
|
||||
Notification::assertSentTo($developer, WebPushNotification::class);
|
||||
Notification::assertSentTo($kasbonUser, WebPushNotification::class);
|
||||
|
||||
expect($kasbonUser->notifications()->where('title', 'Kasbon Dikembalikan')->count())->toBe(1);
|
||||
expect($kasbonUser->notifications()->where('title', 'Kasbon Dibayar')->count())->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||