refactor: simplify Home.vue by removing unused price filters and interfaces, and enhance product display with new components
This commit is contained in:
parent
18ea643312
commit
11232293a9
131
resources/js/components/card/ProductCard.vue
Normal file
131
resources/js/components/card/ProductCard.vue
Normal file
@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { formatRupiah } from '@/lib/rupiah';
|
||||
import type { Product } from '@/types/product';
|
||||
|
||||
defineProps<{
|
||||
product: Product;
|
||||
indexOffset?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
quickView: [product: Product];
|
||||
}>();
|
||||
|
||||
const getProductImage = (product: Product, indexOffset = 0) => {
|
||||
for (const variant of product.variants) {
|
||||
if (variant.images && variant.images.length > 0) {
|
||||
return variant.images[0].url;
|
||||
}
|
||||
}
|
||||
|
||||
const firstCat = product.categories?.[0]?.slug || '';
|
||||
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',
|
||||
'https://images.unsplash.com/photo-1618244972963-dbee1a7edc95?w=800&auto=format&fit=crop&q=80'
|
||||
],
|
||||
'atasan': [
|
||||
'https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1532244769068-b223ede82d7b?w=800&auto=format&fit=crop&q=80'
|
||||
],
|
||||
'bawahan': [
|
||||
'https://images.unsplash.com/photo-1583496661160-fb4886b36ca7?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1551163943-3f6a855d1153?w=800&auto=format&fit=crop&q=80'
|
||||
],
|
||||
'busui': [
|
||||
'https://images.unsplash.com/photo-1509631179647-0177331693ae?w=800&auto=format&fit=crop&q=80',
|
||||
'https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=800&auto=format&fit=crop&q=80'
|
||||
]
|
||||
};
|
||||
|
||||
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[indexOffset % categoryUrls.length];
|
||||
};
|
||||
|
||||
const getProductPriceRange = (product: Product) => {
|
||||
const prices = product.variants.flatMap(v =>
|
||||
v.prices.filter(p => p.type === 'ecer').map(p => p.price)
|
||||
);
|
||||
|
||||
if (prices.length === 0) {
|
||||
const fallbackPrices = product.variants.flatMap(v => v.prices.map(p => p.price));
|
||||
|
||||
if (fallbackPrices.length === 0) {
|
||||
return 'Rp 0';
|
||||
}
|
||||
|
||||
const min = Math.min(...fallbackPrices);
|
||||
const max = Math.max(...fallbackPrices);
|
||||
|
||||
return min === max ? `Rp ${formatRupiah(min)}` : `Rp ${formatRupiah(min)} - Rp ${formatRupiah(max)}`;
|
||||
}
|
||||
|
||||
const min = Math.min(...prices);
|
||||
const max = Math.max(...prices);
|
||||
|
||||
return min === max ? `Rp ${formatRupiah(min)}` : `Rp ${formatRupiah(min)} - Rp ${formatRupiah(max)}`;
|
||||
};
|
||||
|
||||
const getTotalStock = (product: Product) => {
|
||||
return product.variants.reduce((acc, variant) => acc + variant.stock, 0);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group relative flex flex-col space-y-4 cursor-pointer" @click="emit('quickView', product)">
|
||||
<!-- Product Image Container -->
|
||||
<div
|
||||
class="relative aspect-3/4 w-full overflow-hidden rounded-2xl bg-amber-50/50 shadow-md transition-shadow hover">
|
||||
<img :src="getProductImage(product, indexOffset ?? 0)" :alt="product.name"
|
||||
class="h-full w-full object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105" />
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-amber-950/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
</div>
|
||||
|
||||
<!-- Status Badges -->
|
||||
<div class="absolute top-4 left-4 flex flex-col space-y-2">
|
||||
<span v-if="getTotalStock(product) === 0"
|
||||
class="bg-red-500 text-white text-[9px] font-bold uppercase tracking-wider px-3 py-1 rounded-md">
|
||||
Habis
|
||||
</span>
|
||||
<span v-else-if="getTotalStock(product) < 15"
|
||||
class="bg-amber-500 text-white text-[9px] font-bold uppercase tracking-wider px-3 py-1 rounded-md animate-pulse">
|
||||
Menipis
|
||||
</span>
|
||||
<span v-else
|
||||
class="bg-amber-600 text-white text-[9px] font-bold uppercase tracking-wider px-3 py-1 rounded-md">
|
||||
Terbaru
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Details Info -->
|
||||
<div class="flex flex-col space-y-2">
|
||||
<!-- Category Badges -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span v-for="cat in product.categories" :key="cat.id"
|
||||
class="inline-block text-[9px] font-bold uppercase tracking-widest text-amber-700 bg-amber-100/60 px-2 py-0.5 rounded-full">
|
||||
{{ cat.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 class="text-base font-semibold text-slate-700 line-clamp-1 group-hover transition-colors">
|
||||
{{ product.name }}
|
||||
</h4>
|
||||
<p class="text-sm font-bold text-amber-600">
|
||||
{{ getProductPriceRange(product) }}
|
||||
</p>
|
||||
<span class="text-[11px] text-slate-400 font-light mt-0.5">
|
||||
Ukuran tersedia: {{ product.variants.map(v => v.name).join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
233
resources/js/components/modal/ProductModal.vue
Normal file
233
resources/js/components/modal/ProductModal.vue
Normal file
@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from '@lucide/vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { formatRupiah } from '@/lib/rupiah';
|
||||
import type { Product, Variant, Price, MediaItem } from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
product: Product | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
addToCart: [product: Product, variant: Variant, price: Price];
|
||||
}>();
|
||||
|
||||
const activeVariant = ref<Variant | null>(null);
|
||||
const activePriceType = ref<string>('ecer');
|
||||
const activeImageIndex = ref(0);
|
||||
|
||||
const allImages = computed<MediaItem[]>(() => {
|
||||
if (!props.product) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.product.variants.flatMap(v => v.images);
|
||||
});
|
||||
|
||||
const mainImage = computed(() => {
|
||||
if (allImages.value.length > 0) {
|
||||
const img = allImages.value[activeImageIndex.value];
|
||||
|
||||
if (img) {
|
||||
return img.url;
|
||||
}
|
||||
}
|
||||
|
||||
return getFallbackImage();
|
||||
});
|
||||
|
||||
watch(() => props.product, (newProduct) => {
|
||||
if (newProduct) {
|
||||
activeVariant.value = newProduct.variants[0] || null;
|
||||
activePriceType.value = 'ecer';
|
||||
activeImageIndex.value = 0;
|
||||
} else {
|
||||
activeVariant.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedPrice = computed(() => {
|
||||
if (!activeVariant.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activeVariant.value.prices.find(p => p.type === activePriceType.value) || activeVariant.value.prices[0] || null;
|
||||
});
|
||||
|
||||
const getFallbackImage = () => {
|
||||
if (!props.product) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const firstCat = props.product.categories?.[0]?.slug || '';
|
||||
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',
|
||||
],
|
||||
'busui': [
|
||||
'https://images.unsplash.com/photo-1509631179647-0177331693ae?w=800&auto=format&fit=crop&q=80',
|
||||
]
|
||||
};
|
||||
|
||||
const categoryUrls = fallbacks[firstCat] || [
|
||||
'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=800&auto=format&fit=crop&q=80',
|
||||
];
|
||||
|
||||
return categoryUrls[0];
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (props.product && activeVariant.value && selectedPrice.value) {
|
||||
emit('addToCart', props.product, activeVariant.value, selectedPrice.value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="product"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm transition-opacity duration-300"
|
||||
@click.self="emit('close')">
|
||||
<div
|
||||
class="w-full max-w-3xl bg-[#FDFDFC] rounded-3xl p-6 md:p-8 shadow-2xl flex flex-col md:flex-row gap-8 max-h-[90vh] overflow-y-auto border border-amber-100/30 scrollbar-thin">
|
||||
<!-- Image Side -->
|
||||
<div class="w-full md:w-1/2 flex flex-col space-y-3">
|
||||
<div
|
||||
class="aspect-3/4 rounded-2xl overflow-hidden bg-slate-50 border border-slate-200/50 shadow-sm relative">
|
||||
<img :src="mainImage" :alt="product.name" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
|
||||
<!-- Image Thumbnails -->
|
||||
<div v-if="allImages.length > 1" class="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
|
||||
<button v-for="(img, idx) in allImages" :key="img.id"
|
||||
@click="activeImageIndex = idx" :class="[
|
||||
'shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all cursor-pointer',
|
||||
activeImageIndex === idx
|
||||
? 'border-amber-500 shadow-md shadow-amber-200/40'
|
||||
: 'border-slate-200/60 opacity-60 hover:opacity-100'
|
||||
]">
|
||||
<img :src="img.thumb_url || img.url" :alt="`Gambar ${idx + 1}`"
|
||||
class="w-full h-full object-cover" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Side -->
|
||||
<div class="w-full md:w-1/2 flex flex-col justify-between space-y-6 text-left">
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<span class="text-[10px] font-bold uppercase tracking-widest text-amber-600">
|
||||
{{product.categories.map(c => c.name).join(', ')}}
|
||||
</span>
|
||||
<h3 class="text-2xl font-serif mt-0.5 text-slate-800">{{ product.name }}</h3>
|
||||
</div>
|
||||
<button @click="emit('close')"
|
||||
class="p-1.5 rounded-full hover:bg-slate-100 transition-colors cursor-pointer">
|
||||
<X class="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-1.5">
|
||||
<h4 class="text-[10px] uppercase font-bold tracking-widest text-slate-400">Deskripsi</h4>
|
||||
<p
|
||||
class="text-xs text-slate-500 font-light leading-relaxed max-h-32 overflow-y-auto scrollbar-thin pr-1">
|
||||
{{ product.description || `Pakaian batik modern dan nyaman hasil karya perajin
|
||||
terbaik kami.Menggunakan serat kain pilihan yang awet dan menyejukkan kulit.` }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Variant Select -->
|
||||
<div v-if="product.variants.length > 0" class="space-y-2">
|
||||
<h4 class="text-[10px] uppercase font-bold tracking-widest text-slate-400">Pilih Ukuran /
|
||||
Varian</h4>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="variant in product.variants" :key="variant.id"
|
||||
@click="activeVariant = variant" :class="[
|
||||
'px-3 py-1.5 rounded-xl text-xs font-semibold uppercase tracking-wider border cursor-pointer transition-all',
|
||||
activeVariant?.id === variant.id
|
||||
? 'bg-amber-600 text-white border-transparent shadow-md shadow-amber-200/30'
|
||||
: 'bg-transparent text-slate-600 border-slate-200/60'
|
||||
]">
|
||||
{{ variant.name }}
|
||||
<span class="text-[9px] opacity-70 ml-1">({{ variant.stock }} pcs)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price Tiers -->
|
||||
<div v-if="activeVariant && activeVariant.prices.length > 0" class="space-y-2">
|
||||
<h4 class="text-[10px] uppercase font-bold tracking-widest text-slate-400">Saluran Harga
|
||||
(Grosir/Eceran)</h4>
|
||||
<div class="grid grid-cols-2 gap-1.5 max-h-36 overflow-y-auto scrollbar-thin pr-1">
|
||||
<button v-for="price in activeVariant.prices" :key="price.id"
|
||||
@click="activePriceType = price.type" :class="[
|
||||
'px-3 py-2 text-left rounded-xl border transition-all cursor-pointer',
|
||||
activePriceType === price.type
|
||||
? 'border-amber-500 bg-amber-500/5/5'
|
||||
: 'border-slate-200/60 hover'
|
||||
]">
|
||||
<span class="block text-[8px] uppercase tracking-wider font-bold text-slate-400">
|
||||
{{ price.type_label }}
|
||||
</span>
|
||||
<span class="block text-xs font-bold mt-0.5 text-amber-600">
|
||||
{{ `Rp ${formatRupiah(price.price)}` }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cart Call to Action -->
|
||||
<div class="pt-4 border-t border-slate-100/50 space-y-4">
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<div>
|
||||
<span class="text-[10px] uppercase font-bold tracking-widest text-slate-400">Harga
|
||||
Terpilih</span>
|
||||
<div class="text-xl font-bold text-amber-600 mt-0.5">
|
||||
{{ selectedPrice ? `Rp ${formatRupiah(selectedPrice.price)}` : 'Pilih ukuran' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-[10px] uppercase font-bold tracking-widest text-slate-400">Sisa
|
||||
Stok</span>
|
||||
<div class="font-semibold text-slate-700 mt-0.5">
|
||||
{{ activeVariant ? `${activeVariant.stock} pcs` : 'Pilih ukuran' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="activeVariant && selectedPrice" @click="handleAddToCart"
|
||||
:disabled="activeVariant.stock <= 0" :class="[
|
||||
'w-full py-3.5 text-xs font-bold uppercase tracking-widest rounded-xl transition-all cursor-pointer shadow-md',
|
||||
activeVariant.stock > 0
|
||||
? 'bg-amber-600 text-white hover:bg-amber-500 shadow-amber-200/50'
|
||||
: 'bg-slate-100 text-slate-400 cursor-not-allowed shadow-none'
|
||||
]">
|
||||
{{ activeVariant.stock > 0 ? 'Masukkan ke Keranjang' : 'Stok Habis' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
136
resources/js/components/ui/carousel/DraggableCarousel.vue
Normal file
136
resources/js/components/ui/carousel/DraggableCarousel.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
|
||||
const track = ref<HTMLElement | null>(null);
|
||||
const canScrollLeft = ref(false);
|
||||
const canScrollRight = ref(false);
|
||||
|
||||
// Drag state
|
||||
const isDragging = ref(false);
|
||||
const dragStartX = ref(0);
|
||||
const dragScrollStart = ref(0);
|
||||
const hasDragged = ref(false);
|
||||
let dragRafId = 0;
|
||||
|
||||
const updateScrollButtons = () => {
|
||||
const el = track.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
canScrollLeft.value = el.scrollLeft > 4;
|
||||
canScrollRight.value = el.scrollLeft + el.clientWidth < el.scrollWidth - 4;
|
||||
};
|
||||
|
||||
const scrollTo = (direction: 'left' | 'right') => {
|
||||
const el = track.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = el.clientWidth * 0.6;
|
||||
el.style.scrollBehavior = 'smooth';
|
||||
el.scrollBy({ left: direction === 'left' ? -amount : amount });
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.style.scrollBehavior = '';
|
||||
});
|
||||
};
|
||||
|
||||
const onDragStart = (e: MouseEvent | TouchEvent) => {
|
||||
const el = track.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging.value = true;
|
||||
hasDragged.value = false;
|
||||
dragStartX.value = 'touches' in e ? e.touches[0].pageX : e.pageX;
|
||||
dragScrollStart.value = el.scrollLeft;
|
||||
};
|
||||
|
||||
const onDragMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const el = track.value;
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentX = 'touches' in e ? e.touches[0].pageX : e.pageX;
|
||||
const delta = currentX - dragStartX.value;
|
||||
|
||||
if (Math.abs(delta) > 3) {
|
||||
hasDragged.value = true;
|
||||
}
|
||||
|
||||
cancelAnimationFrame(dragRafId);
|
||||
dragRafId = requestAnimationFrame(() => {
|
||||
el.scrollLeft = dragScrollStart.value - delta;
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
isDragging.value = false;
|
||||
cancelAnimationFrame(dragRafId);
|
||||
nextTick(() => updateScrollButtons());
|
||||
};
|
||||
|
||||
const preventClick = (e: Event) => {
|
||||
if (hasDragged.value) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => updateScrollButtons());
|
||||
});
|
||||
|
||||
defineExpose({ updateScrollButtons });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center min-w-0">
|
||||
<button v-show="canScrollLeft" @click="scrollTo('left')"
|
||||
class="absolute left-0 z-10 flex items-center justify-center w-7 h-7 rounded-full bg-white/90 border border-slate-200 shadow-sm hover:bg-amber-50 hover:border-amber-300 transition-all cursor-pointer -ml-1">
|
||||
<ChevronLeft class="w-4 h-4 text-slate-500" />
|
||||
</button>
|
||||
|
||||
<div ref="track" @scroll="updateScrollButtons"
|
||||
@mousedown="onDragStart" @mousemove="onDragMove" @mouseup="onDragEnd" @mouseleave="onDragEnd"
|
||||
@touchstart.passive="onDragStart" @touchmove="onDragMove" @touchend="onDragEnd"
|
||||
@click.capture="preventClick"
|
||||
class="flex gap-2 overflow-x-auto scrollbar-hide px-1 py-1 select-none touch-pan-y will-change-scroll"
|
||||
:class="[
|
||||
{ 'mx-8': canScrollLeft || canScrollRight },
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-grab'
|
||||
]">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<button v-show="canScrollRight" @click="scrollTo('right')"
|
||||
class="absolute right-0 z-10 flex items-center justify-center w-7 h-7 rounded-full bg-white/90 border border-slate-200 shadow-sm hover:bg-amber-50 hover:border-amber-300 transition-all cursor-pointer -mr-1">
|
||||
<ChevronRight class="w-4 h-4 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user