feat: add cart context and functionality, update welcome page with new components and styles

- Implemented CartContext for managing cart state, including adding, removing, and updating items.
- Added CartDrawer component to display cart items.
- Updated AppHeader to include cart item count.
- Enhanced welcome page with new layout, improved product image handling, and updated call-to-action buttons.
- Refactored homepage data types to accommodate new features and removed unused properties.
This commit is contained in:
Yoga Pangestu 2026-08-13 17:19:56 +07:00
parent f7c9891787
commit 7d21a1e6db
13 changed files with 654 additions and 515 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { FlashToast, PWAUpdateToast } from '@/components/notifications';
import { Toaster } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { CartProvider } from '@/contexts/cart-context';
import { initializeTheme } from '@/hooks/use-appearance';
import AppLayout from '@/layouts/app-layout';
import AuthLayout from '@/layouts/auth-layout';
@ -38,12 +39,14 @@ createInertiaApp({
strictMode: true,
withApp(app) {
return (
<TooltipProvider delayDuration={0}>
<FlashToast />
<PWAUpdateToast />
{app}
<Toaster />
</TooltipProvider>
<CartProvider>
<TooltipProvider delayDuration={0}>
<FlashToast />
<PWAUpdateToast />
{app}
<Toaster />
</TooltipProvider>
</CartProvider>
);
},
progress: {

View File

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

View File

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

View File

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

View File

@ -141,7 +141,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
</div>
<div className="ml-auto flex items-center space-x-2">
<DropdownMenu>
<DropdownMenu>k
<DropdownMenuTrigger asChild>
<Button
variant="ghost"

View File

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

View File

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

View File

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

View File

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