2 Commits

Author SHA1 Message Date
Grey 913af09e3d Merge branch 'dev'
Deploy SecondTech / deploy (push) Successful in 4s
2026-06-02 08:57:19 +07:00
Grey 90b925a0f5 edit bagain input no wa 2026-06-02 08:56:05 +07:00
5 changed files with 398 additions and 206 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+171 -96
View File
@@ -2,7 +2,9 @@
<section class="container-page py-10"> <section class="container-page py-10">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-extrabold">Edit Barang</h1> <h1 class="text-3xl font-extrabold">Edit Barang</h1>
<p class="mt-2 text-slate-600">Ubah informasi barang yang sudah kamu posting.</p> <p class="mt-2 text-slate-600">
Ubah informasi barang yang sudah kamu posting.
</p>
</div> </div>
<div v-if="loadingProduct" class="card p-10 text-center"> <div v-if="loadingProduct" class="card p-10 text-center">
@@ -11,31 +13,51 @@
<div v-else-if="notFound" class="card p-10 text-center"> <div v-else-if="notFound" class="card p-10 text-center">
<h2 class="text-xl font-extrabold">Barang tidak ditemukan</h2> <h2 class="text-xl font-extrabold">Barang tidak ditemukan</h2>
<p class="mt-2 text-slate-600">Barang yang ingin kamu edit tidak ditemukan.</p> <p class="mt-2 text-slate-600">
<RouterLink to="/dashboard" class="btn-primary mt-5">Kembali ke Dashboard</RouterLink> Barang yang ingin kamu edit tidak ditemukan.
</p>
<RouterLink to="/dashboard" class="btn-primary mt-5"
>Kembali ke Dashboard</RouterLink
>
</div> </div>
<div v-else class="grid gap-8 lg:grid-cols-[1fr_360px]"> <div v-else class="grid gap-8 lg:grid-cols-[1fr_360px]">
<form class="card grid gap-5 p-6" @submit.prevent="submitEdit"> <form class="card grid gap-5 p-6" @submit.prevent="submitEdit">
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"> <p
v-if="errorMessage"
class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"
>
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<div> <div>
<label class="label">Nama Barang</label> <label class="label">Nama Barang</label>
<input v-model="form.title" class="input mt-2" placeholder="Contoh: Laptop ThinkPad T480" required /> <input
v-model="form.title"
class="input mt-2"
placeholder="Contoh: Laptop ThinkPad T480"
required
/>
</div> </div>
<div class="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div> <div>
<label class="label">Kategori</label> <label class="label">Kategori</label>
<select v-model="form.category" class="input mt-2" required> <select v-model="form.category" class="input mt-2" required>
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option> <option v-for="cat in realCategories" :key="cat" :value="cat">
{{ cat }}
</option>
</select> </select>
</div> </div>
<div> <div>
<label class="label">Harga</label> <label class="label">Harga</label>
<input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required /> <input
v-model.number="form.price"
type="number"
class="input mt-2"
placeholder="2500000"
required
/>
</div> </div>
</div> </div>
@@ -51,25 +73,41 @@
</div> </div>
<div> <div>
<label class="label">Lokasi</label> <label class="label">Lokasi</label>
<input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required /> <input
v-model="form.location"
class="input mt-2"
placeholder="Yogyakarta"
required
/>
</div> </div>
</div> </div>
<div class="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div> <div>
<label class="label">Nama Penjual</label> <label class="label">Nama Penjual</label>
<input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required /> <input
v-model="form.seller"
class="input mt-2"
placeholder="Nama kamu"
required
/>
</div> </div>
<div> <div>
<label class="label">Nomor WhatsApp</label> <label class="label">Nomor WhatsApp +62</label>
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required /> <input
v-model="form.whatsapp"
class="input mt-2"
placeholder="+6281234567890"
required
/>
</div> </div>
</div> </div>
<div> <div>
<label class="label">Foto Barang</label> <label class="label">Foto Barang</label>
<p v-if="existingImages.length" class="mb-3 text-sm text-slate-500"> <p v-if="existingImages.length" class="mb-3 text-sm text-slate-500">
{{ existingImages.length }} foto saat ini. Upload foto baru jika ingin mengganti. {{ existingImages.length }} foto saat ini. Upload foto baru jika
ingin mengganti.
</p> </p>
<input <input
@@ -81,39 +119,67 @@
@change="handleImageChange" @change="handleImageChange"
/> />
<div v-if="selectedImages.length" class="mt-3 flex items-center justify-between gap-3 rounded-xl bg-brand-50 px-4 py-3 text-sm font-bold text-brand-700"> <div
v-if="selectedImages.length"
class="mt-3 flex items-center justify-between gap-3 rounded-xl bg-brand-50 px-4 py-3 text-sm font-bold text-brand-700"
>
<span>{{ selectedImages.length }} foto baru dipilih</span> <span>{{ selectedImages.length }} foto baru dipilih</span>
<button type="button" class="text-red-700" @click="clearImages">Hapus semua</button> <button type="button" class="text-red-700" @click="clearImages">
Hapus semua
</button>
</div> </div>
<div v-if="previewUrls.length" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3"> <div
<div v-for="(preview, index) in previewUrls" :key="preview.url" class="overflow-hidden rounded-xl border border-slate-200"> v-if="previewUrls.length"
<img :src="preview.url" :alt="`Preview foto barang ${index + 1}`" class="aspect-[4/3] w-full object-cover" /> class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3"
<button type="button" class="w-full bg-red-50 px-3 py-2 text-xs font-bold text-red-700" @click="removeImage(index)"> >
<div
v-for="(preview, index) in previewUrls"
:key="preview.url"
class="overflow-hidden rounded-xl border border-slate-200"
>
<img
:src="preview.url"
:alt="`Preview foto barang ${index + 1}`"
class="aspect-[4/3] w-full object-cover"
/>
<button
type="button"
class="w-full bg-red-50 px-3 py-2 text-xs font-bold text-red-700"
@click="removeImage(index)"
>
Hapus foto Hapus foto
</button> </button>
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-slate-500"> <p class="mt-2 text-xs text-slate-500">
Maksimal 6 foto, 10 MB per foto. Biarkan kosong jika tidak ingin mengganti foto. Maksimal 6 foto, 10 MB per foto. Biarkan kosong jika tidak ingin
mengganti foto.
</p> </p>
</div> </div>
<div> <div>
<label class="label">Deskripsi</label> <label class="label">Deskripsi</label>
<textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Jelaskan kondisi barang..." required></textarea> <textarea
v-model="form.description"
class="input mt-2 min-h-32"
placeholder="Jelaskan kondisi barang..."
required
></textarea>
</div> </div>
<button class="btn-primary w-full" :disabled="loading"> <button class="btn-primary w-full" :disabled="loading">
{{ loading ? "Menyimpan..." : "Simpan Perubahan" }} {{ loading ? 'Menyimpan...' : 'Simpan Perubahan' }}
</button> </button>
</form> </form>
<div class="card h-fit p-6"> <div class="card h-fit p-6">
<h2 class="text-lg font-extrabold">Tips Edit Produk</h2> <h2 class="text-lg font-extrabold">Tips Edit Produk</h2>
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600"> <ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600">
<li>- Perbarui judul dan deskripsi jika ada perubahan kondisi barang.</li> <li>
- Perbarui judul dan deskripsi jika ada perubahan kondisi barang.
</li>
<li>- Upload foto baru jika kondisi fisik barang berubah.</li> <li>- Upload foto baru jika kondisi fisik barang berubah.</li>
<li>- Pastikan nomor WhatsApp masih aktif.</li> <li>- Pastikan nomor WhatsApp masih aktif.</li>
</ul> </ul>
@@ -123,126 +189,135 @@
</template> </template>
<script setup> <script setup>
import { reactive, computed, ref, onMounted } from "vue" import { reactive, computed, ref, onMounted } from 'vue';
import { useRoute, useRouter } from "vue-router" import { useRoute, useRouter } from 'vue-router';
import { categories } from "../data/products" import { categories } from '../data/products';
import { fetchProduct, updateProduct } from "../utils" import { fetchProduct, updateProduct } from '../utils';
const route = useRoute() const route = useRoute();
const router = useRouter() const router = useRouter();
const realCategories = computed(() => categories.filter((cat) => cat !== "Semua")) const realCategories = computed(() =>
const fileInput = ref(null) categories.filter((cat) => cat !== 'Semua')
const MAX_IMAGES = 6 );
const MAX_IMAGE_SIZE_MB = 10 const fileInput = ref(null);
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024 const MAX_IMAGES = 6;
const MAX_IMAGE_SIZE_MB = 10;
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024;
const form = reactive({ const form = reactive({
title: "", title: '',
category: "Laptop", category: 'Laptop',
price: "", price: '',
condition: "Bekas Normal", condition: 'Bekas Normal',
location: "", location: '',
seller: "", seller: '',
whatsapp: "", whatsapp: '',
description: "" description: '',
}) });
const existingImages = ref([]) const existingImages = ref([]);
const selectedImages = ref([]) const selectedImages = ref([]);
const previewUrls = ref([]) const previewUrls = ref([]);
const loading = ref(false) const loading = ref(false);
const loadingProduct = ref(true) const loadingProduct = ref(true);
const notFound = ref(false) const notFound = ref(false);
const errorMessage = ref("") const errorMessage = ref('');
function imageKey(file) { function imageKey(file) {
return `${file.name}-${file.size}-${file.lastModified}` return `${file.name}-${file.size}-${file.lastModified}`;
} }
function rebuildPreviews() { function rebuildPreviews() {
previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url)) previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url));
previewUrls.value = selectedImages.value.map((file) => ({ previewUrls.value = selectedImages.value.map((file) => ({
key: imageKey(file), key: imageKey(file),
url: URL.createObjectURL(file) url: URL.createObjectURL(file),
})) }));
} }
function handleImageChange(event) { function handleImageChange(event) {
const incomingFiles = Array.from(event.target.files || []) const incomingFiles = Array.from(event.target.files || []);
const oversizedFile = incomingFiles.find((file) => file.size > MAX_IMAGE_SIZE) const oversizedFile = incomingFiles.find(
(file) => file.size > MAX_IMAGE_SIZE
);
if (oversizedFile) { if (oversizedFile) {
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.` errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`;
event.target.value = "" event.target.value = '';
return return;
} }
const merged = [...selectedImages.value] const merged = [...selectedImages.value];
incomingFiles.forEach((file) => { incomingFiles.forEach((file) => {
if (!merged.some((item) => imageKey(item) === imageKey(file)) && merged.length < MAX_IMAGES) { if (
merged.push(file) !merged.some((item) => imageKey(item) === imageKey(file)) &&
merged.length < MAX_IMAGES
) {
merged.push(file);
} }
}) });
selectedImages.value = merged selectedImages.value = merged;
rebuildPreviews() rebuildPreviews();
errorMessage.value = "" errorMessage.value = '';
event.target.value = "" event.target.value = '';
} }
function removeImage(index) { function removeImage(index) {
selectedImages.value = selectedImages.value.filter((_, itemIndex) => itemIndex !== index) selectedImages.value = selectedImages.value.filter(
rebuildPreviews() (_, itemIndex) => itemIndex !== index
);
rebuildPreviews();
} }
function clearImages() { function clearImages() {
selectedImages.value = [] selectedImages.value = [];
rebuildPreviews() rebuildPreviews();
if (fileInput.value) fileInput.value.value = "" if (fileInput.value) fileInput.value.value = '';
} }
async function submitEdit() { async function submitEdit() {
loading.value = true loading.value = true;
errorMessage.value = "" errorMessage.value = '';
const payload = new FormData() const payload = new FormData();
Object.entries(form).forEach(([key, value]) => { Object.entries(form).forEach(([key, value]) => {
payload.append(key, value) payload.append(key, value);
}) });
selectedImages.value.forEach((image) => { selectedImages.value.forEach((image) => {
payload.append("images", image) payload.append('images', image);
}) });
try { try {
await updateProduct(route.params.id, payload) await updateProduct(route.params.id, payload);
router.push("/dashboard") router.push('/dashboard');
} catch (error) { } catch (error) {
errorMessage.value = error.message errorMessage.value = error.message;
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
onMounted(async () => { onMounted(async () => {
try { try {
const data = await fetchProduct(route.params.id) const data = await fetchProduct(route.params.id);
const product = data.product const product = data.product;
form.title = product.title form.title = product.title;
form.category = product.category form.category = product.category;
form.price = product.price form.price = product.price;
form.condition = product.condition form.condition = product.condition;
form.location = product.location form.location = product.location;
form.seller = product.seller form.seller = product.seller;
form.whatsapp = product.whatsapp form.whatsapp = product.whatsapp;
form.description = product.description form.description = product.description;
if (product.images && product.images.length) { if (product.images && product.images.length) {
existingImages.value = product.images existingImages.value = product.images;
} }
} catch { } catch {
notFound.value = true notFound.value = true;
} finally { } finally {
loadingProduct.value = false loadingProduct.value = false;
} }
}) });
</script> </script>
+75 -29
View File
@@ -2,73 +2,119 @@
<section class="container-page grid min-h-[70vh] place-items-center py-10"> <section class="container-page grid min-h-[70vh] place-items-center py-10">
<div class="card w-full max-w-md p-6"> <div class="card w-full max-w-md p-6">
<h1 class="text-2xl font-extrabold">Register</h1> <h1 class="text-2xl font-extrabold">Register</h1>
<p class="mt-2 text-sm text-slate-600">Buat akun untuk mulai menjual barang.</p> <p class="mt-2 text-sm text-slate-600">
Buat akun untuk mulai menjual barang.
</p>
<form class="mt-6 grid gap-4" @submit.prevent="register"> <form class="mt-6 grid gap-4" @submit.prevent="register">
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"> <p
v-if="errorMessage"
class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"
>
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<div> <div>
<label class="label">Nama Lengkap</label> <label class="label">Nama Lengkap</label>
<input v-model="form.name" class="input mt-2" placeholder="Nama kamu" required /> <input
v-model="form.name"
class="input mt-2"
placeholder="Nama kamu"
required
/>
</div> </div>
<div> <div>
<label class="label">Email</label> <label class="label">Email</label>
<input v-model="form.email" type="email" class="input mt-2" placeholder="nama@email.com" required /> <input
v-model="form.email"
type="email"
class="input mt-2"
placeholder="nama@email.com"
required
/>
</div> </div>
<div> <div>
<label class="label">Nomor WhatsApp</label> <label class="label">Nomor WhatsApp</label>
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required /> <input
v-model="form.whatsapp"
class="input mt-2"
placeholder="+6281234567890"
required
/>
</div> </div>
<div> <div>
<label class="label">Password</label> <label class="label">Password</label>
<input v-model="form.password" type="password" class="input mt-2" placeholder="••••••••" required /> <input
v-model="form.password"
type="password"
class="input mt-2"
placeholder="••••••••"
required
/>
</div> </div>
<button class="btn-primary w-full" :disabled="loading"> <button class="btn-primary w-full" :disabled="loading">
<svg v-if="loading" class="mr-3 h-5 w-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> v-if="loading"
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> class="mr-3 h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
{{ loading ? "Memproses..." : "Daftar" }} {{ loading ? 'Memproses...' : 'Daftar' }}
</button> </button>
</form> </form>
<p class="mt-5 text-center text-sm text-slate-600"> <p class="mt-5 text-center text-sm text-slate-600">
Sudah punya akun? Sudah punya akun?
<RouterLink to="/login" class="font-bold text-brand-700">Login</RouterLink> <RouterLink to="/login" class="font-bold text-brand-700"
>Login</RouterLink
>
</p> </p>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { reactive, ref } from "vue" import { reactive, ref } from 'vue';
import { useRouter } from "vue-router" import { useRouter } from 'vue-router';
import { registerUser, setAuth } from "../utils" import { registerUser, setAuth } from '../utils';
const router = useRouter() const router = useRouter();
const form = reactive({ const form = reactive({
name: "", name: '',
email: "", email: '',
whatsapp: "", whatsapp: '',
password: "" password: '',
}) });
const loading = ref(false) const loading = ref(false);
const errorMessage = ref("") const errorMessage = ref('');
async function register() { async function register() {
loading.value = true loading.value = true;
errorMessage.value = "" errorMessage.value = '';
try { try {
const data = await registerUser(form) const data = await registerUser(form);
setAuth(data) setAuth(data);
router.push("/dashboard") router.push('/dashboard');
} catch (error) { } catch (error) {
errorMessage.value = error.message errorMessage.value = error.message;
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
</script> </script>
+151 -80
View File
@@ -2,30 +2,49 @@
<section class="container-page py-10"> <section class="container-page py-10">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-extrabold">Jual Barang</h1> <h1 class="text-3xl font-extrabold">Jual Barang</h1>
<p class="mt-2 text-slate-600">Posting barang ke marketplace dengan beberapa foto asli dari perangkatmu.</p> <p class="mt-2 text-slate-600">
Posting barang ke marketplace dengan beberapa foto asli dari
perangkatmu.
</p>
</div> </div>
<div class="grid gap-8 lg:grid-cols-[1fr_360px]"> <div class="grid gap-8 lg:grid-cols-[1fr_360px]">
<form class="card grid gap-5 p-6" @submit.prevent="submitProduct"> <form class="card grid gap-5 p-6" @submit.prevent="submitProduct">
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"> <p
v-if="errorMessage"
class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"
>
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<div> <div>
<label class="label">Nama Barang</label> <label class="label">Nama Barang</label>
<input v-model="form.title" class="input mt-2" placeholder="Contoh: Laptop ThinkPad T480" required /> <input
v-model="form.title"
class="input mt-2"
placeholder="Contoh: Laptop ThinkPad T480"
required
/>
</div> </div>
<div class="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div> <div>
<label class="label">Kategori</label> <label class="label">Kategori</label>
<select v-model="form.category" class="input mt-2" required> <select v-model="form.category" class="input mt-2" required>
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option> <option v-for="cat in realCategories" :key="cat" :value="cat">
{{ cat }}
</option>
</select> </select>
</div> </div>
<div> <div>
<label class="label">Harga</label> <label class="label">Harga</label>
<input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required /> <input
v-model.number="form.price"
type="number"
class="input mt-2"
placeholder="2500000"
required
/>
</div> </div>
</div> </div>
@@ -41,18 +60,33 @@
</div> </div>
<div> <div>
<label class="label">Lokasi</label> <label class="label">Lokasi</label>
<input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required /> <input
v-model="form.location"
class="input mt-2"
placeholder="Yogyakarta"
required
/>
</div> </div>
</div> </div>
<div class="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div> <div>
<label class="label">Nama Penjual</label> <label class="label">Nama Penjual</label>
<input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required /> <input
v-model="form.seller"
class="input mt-2"
placeholder="Nama kamu"
required
/>
</div> </div>
<div> <div>
<label class="label">Nomor WhatsApp</label> <label class="label">Nomor WhatsApp +62</label>
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required /> <input
v-model="form.whatsapp"
class="input mt-2"
placeholder="+6281234567890"
required
/>
</div> </div>
</div> </div>
@@ -67,39 +101,67 @@
@change="handleImageChange" @change="handleImageChange"
/> />
<div v-if="selectedImages.length" class="mt-3 flex items-center justify-between gap-3 rounded-xl bg-brand-50 px-4 py-3 text-sm font-bold text-brand-700"> <div
v-if="selectedImages.length"
class="mt-3 flex items-center justify-between gap-3 rounded-xl bg-brand-50 px-4 py-3 text-sm font-bold text-brand-700"
>
<span>{{ selectedImages.length }} foto dipilih</span> <span>{{ selectedImages.length }} foto dipilih</span>
<button type="button" class="text-red-700" @click="clearImages">Hapus semua</button> <button type="button" class="text-red-700" @click="clearImages">
Hapus semua
</button>
</div> </div>
<div v-if="previewUrls.length" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3"> <div
<div v-for="(preview, index) in previewUrls" :key="preview.url" class="overflow-hidden rounded-xl border border-slate-200"> v-if="previewUrls.length"
<img :src="preview.url" :alt="`Preview foto barang ${index + 1}`" class="aspect-[4/3] w-full object-cover" /> class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3"
<button type="button" class="w-full bg-red-50 px-3 py-2 text-xs font-bold text-red-700" @click="removeImage(index)"> >
<div
v-for="(preview, index) in previewUrls"
:key="preview.url"
class="overflow-hidden rounded-xl border border-slate-200"
>
<img
:src="preview.url"
:alt="`Preview foto barang ${index + 1}`"
class="aspect-[4/3] w-full object-cover"
/>
<button
type="button"
class="w-full bg-red-50 px-3 py-2 text-xs font-bold text-red-700"
@click="removeImage(index)"
>
Hapus foto Hapus foto
</button> </button>
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-slate-500"> <p class="mt-2 text-xs text-slate-500">
Maksimal 6 foto, 10 MB per foto. Kamu boleh pilih beberapa sekaligus atau tambah satu per satu. Maksimal 6 foto, 10 MB per foto. Kamu boleh pilih beberapa sekaligus
atau tambah satu per satu.
</p> </p>
</div> </div>
<div> <div>
<label class="label">Deskripsi</label> <label class="label">Deskripsi</label>
<textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Jelaskan kondisi barang..." required></textarea> <textarea
v-model="form.description"
class="input mt-2 min-h-32"
placeholder="Jelaskan kondisi barang..."
required
></textarea>
</div> </div>
<button class="btn-primary w-full" :disabled="loading"> <button class="btn-primary w-full" :disabled="loading">
{{ loading ? "Memposting..." : "Posting Barang" }} {{ loading ? 'Memposting...' : 'Posting Barang' }}
</button> </button>
</form> </form>
<div class="card h-fit p-6"> <div class="card h-fit p-6">
<h2 class="text-lg font-extrabold">Tips Isi Produk</h2> <h2 class="text-lg font-extrabold">Tips Isi Produk</h2>
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600"> <ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600">
<li>- Pakai judul yang jelas, misal "MikroTik RB941 Bekas Normal".</li> <li>
- Pakai judul yang jelas, misal "MikroTik RB941 Bekas Normal".
</li>
<li>- Jelaskan minus barang kalau ada.</li> <li>- Jelaskan minus barang kalau ada.</li>
<li>- Pakai nomor WhatsApp aktif.</li> <li>- Pakai nomor WhatsApp aktif.</li>
<li>- Upload beberapa foto dari sisi yang berbeda.</li> <li>- Upload beberapa foto dari sisi yang berbeda.</li>
@@ -110,109 +172,118 @@
</template> </template>
<script setup> <script setup>
import { reactive, computed, ref } from "vue" import { reactive, computed, ref } from 'vue';
import { useRouter } from "vue-router" import { useRouter } from 'vue-router';
import { categories } from "../data/products" import { categories } from '../data/products';
import { createProduct, getCurrentUser } from "../utils" import { createProduct, getCurrentUser } from '../utils';
const router = useRouter() const router = useRouter();
const realCategories = computed(() => categories.filter((cat) => cat !== "Semua")) const realCategories = computed(() =>
const fileInput = ref(null) categories.filter((cat) => cat !== 'Semua')
const MAX_IMAGES = 6 );
const MAX_IMAGE_SIZE_MB = 10 const fileInput = ref(null);
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024 const MAX_IMAGES = 6;
const MAX_IMAGE_SIZE_MB = 10;
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024;
const form = reactive({ const form = reactive({
title: "", title: '',
category: "Laptop", category: 'Laptop',
price: "", price: '',
condition: "Bekas Normal", condition: 'Bekas Normal',
location: "", location: '',
seller: "", seller: '',
whatsapp: "", whatsapp: '',
description: "" description: '',
}) });
const selectedImages = ref([]) const selectedImages = ref([]);
const previewUrls = ref([]) const previewUrls = ref([]);
const loading = ref(false) const loading = ref(false);
const errorMessage = ref("") const errorMessage = ref('');
const currentUser = getCurrentUser() const currentUser = getCurrentUser();
if (currentUser) { if (currentUser) {
form.seller = currentUser.name || "" form.seller = currentUser.name || '';
form.whatsapp = currentUser.whatsapp || "" form.whatsapp = currentUser.whatsapp || '';
} }
function imageKey(file) { function imageKey(file) {
return `${file.name}-${file.size}-${file.lastModified}` return `${file.name}-${file.size}-${file.lastModified}`;
} }
function rebuildPreviews() { function rebuildPreviews() {
previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url)) previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url));
previewUrls.value = selectedImages.value.map((file) => ({ previewUrls.value = selectedImages.value.map((file) => ({
key: imageKey(file), key: imageKey(file),
url: URL.createObjectURL(file) url: URL.createObjectURL(file),
})) }));
} }
function handleImageChange(event) { function handleImageChange(event) {
const incomingFiles = Array.from(event.target.files || []) const incomingFiles = Array.from(event.target.files || []);
const oversizedFile = incomingFiles.find((file) => file.size > MAX_IMAGE_SIZE) const oversizedFile = incomingFiles.find(
(file) => file.size > MAX_IMAGE_SIZE
);
if (oversizedFile) { if (oversizedFile) {
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.` errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`;
event.target.value = "" event.target.value = '';
return return;
} }
const merged = [...selectedImages.value] const merged = [...selectedImages.value];
incomingFiles.forEach((file) => { incomingFiles.forEach((file) => {
if (!merged.some((item) => imageKey(item) === imageKey(file)) && merged.length < MAX_IMAGES) { if (
merged.push(file) !merged.some((item) => imageKey(item) === imageKey(file)) &&
merged.length < MAX_IMAGES
) {
merged.push(file);
} }
}) });
selectedImages.value = merged selectedImages.value = merged;
rebuildPreviews() rebuildPreviews();
errorMessage.value = "" errorMessage.value = '';
event.target.value = "" event.target.value = '';
} }
function removeImage(index) { function removeImage(index) {
selectedImages.value = selectedImages.value.filter((_, itemIndex) => itemIndex !== index) selectedImages.value = selectedImages.value.filter(
rebuildPreviews() (_, itemIndex) => itemIndex !== index
);
rebuildPreviews();
} }
function clearImages() { function clearImages() {
selectedImages.value = [] selectedImages.value = [];
rebuildPreviews() rebuildPreviews();
if (fileInput.value) fileInput.value.value = "" if (fileInput.value) fileInput.value.value = '';
} }
async function submitProduct() { async function submitProduct() {
if (!selectedImages.value.length) { if (!selectedImages.value.length) {
errorMessage.value = "Minimal 1 foto barang wajib diupload." errorMessage.value = 'Minimal 1 foto barang wajib diupload.';
return return;
} }
loading.value = true loading.value = true;
errorMessage.value = "" errorMessage.value = '';
const payload = new FormData() const payload = new FormData();
Object.entries(form).forEach(([key, value]) => { Object.entries(form).forEach(([key, value]) => {
payload.append(key, value) payload.append(key, value);
}) });
selectedImages.value.forEach((image) => { selectedImages.value.forEach((image) => {
payload.append("images", image) payload.append('images', image);
}) });
try { try {
await createProduct(payload) await createProduct(payload);
router.push("/dashboard") router.push('/dashboard');
} catch (error) { } catch (error) {
errorMessage.value = error.message errorMessage.value = error.message;
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
</script> </script>