Compare commits
7 Commits
0c54b91ed2
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 90b925a0f5 | |||
| 967a3b7945 | |||
| 3bb305f3a2 | |||
| 8620a3ba96 | |||
| 213930291e | |||
| 0168395347 | |||
| 08821eaff0 |
@@ -1,144 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="container-page py-10">
|
|
||||||
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
|
|
||||||
<- Kembali ke Marketplace
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<div v-if="loading" class="card p-10 text-center">
|
|
||||||
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="product" class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(380px,520px)]">
|
|
||||||
<div class="grid gap-4 sm:grid-cols-[88px_1fr]">
|
|
||||||
<div class="order-2 flex gap-3 overflow-x-auto sm:order-1 sm:grid sm:content-start sm:max-h-[78vh] sm:overflow-y-auto">
|
|
||||||
<button
|
|
||||||
v-for="(image, index) in productImages"
|
|
||||||
:key="`${image}-${index}`"
|
|
||||||
type="button"
|
|
||||||
class="h-20 w-20 shrink-0 overflow-hidden rounded-xl border-2 bg-white"
|
|
||||||
:class="selectedImage === image ? 'border-brand-600' : 'border-slate-200'"
|
|
||||||
@click="selectedImage = image"
|
|
||||||
>
|
|
||||||
<img :src="getProductImageUrl(image)" :alt="`${product.title} ${index + 1}`" class="h-full w-full object-cover" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card order-1 overflow-hidden sm:order-2">
|
|
||||||
<div class="flex min-h-[260px] items-center justify-center bg-black">
|
|
||||||
<img
|
|
||||||
:src="getProductImageUrl(selectedImage)"
|
|
||||||
:alt="product.title"
|
|
||||||
class="h-auto max-h-[78vh] w-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="rounded-full bg-brand-50 px-3 py-1 text-sm font-bold text-brand-700">
|
|
||||||
{{ product.category }}
|
|
||||||
</span>
|
|
||||||
<h1 class="mt-4 text-3xl font-extrabold text-slate-950">{{ product.title }}</h1>
|
|
||||||
<p class="mt-3 text-3xl font-extrabold text-brand-700">{{ formatRupiah(product.price) }}</p>
|
|
||||||
|
|
||||||
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
|
||||||
<div class="card p-4">
|
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Kondisi</p>
|
|
||||||
<p class="mt-1 font-bold">{{ product.condition }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4">
|
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Lokasi</p>
|
|
||||||
<p class="mt-1 font-bold">{{ product.location }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4">
|
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Penjual</p>
|
|
||||||
<p class="mt-1 font-bold">{{ product.seller }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card p-4">
|
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">WhatsApp</p>
|
|
||||||
<p class="mt-1 font-bold">{{ product.whatsapp }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
|
||||||
<p class="mt-2 leading-7 text-slate-600">{{ product.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap gap-3">
|
|
||||||
<a :href="waLink" target="_blank" class="btn-primary">Hubungi via WhatsApp</a>
|
|
||||||
<button class="btn-secondary" @click="toggleSave">
|
|
||||||
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="card p-10 text-center">
|
|
||||||
<h1 class="text-2xl font-extrabold">Produk tidak ditemukan</h1>
|
|
||||||
<RouterLink to="/marketplace" class="btn-primary mt-5">Lihat Marketplace</RouterLink>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted, ref, watch } from "vue"
|
|
||||||
import { useRoute } from "vue-router"
|
|
||||||
import { products as defaultProducts } from "../data/products"
|
|
||||||
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const id = Number(route.params.id)
|
|
||||||
const product = ref(null)
|
|
||||||
const loading = ref(true)
|
|
||||||
const selectedImage = ref("")
|
|
||||||
|
|
||||||
const productImages = computed(() => {
|
|
||||||
if (!product.value) return []
|
|
||||||
const images = product.value.images?.length ? product.value.images : [product.value.image]
|
|
||||||
return [...new Set(images.filter(Boolean))]
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
productImages,
|
|
||||||
(images) => {
|
|
||||||
if (!images.includes(selectedImage.value)) {
|
|
||||||
selectedImage.value = images[0] || ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const savedIds = ref(getSavedProducts())
|
|
||||||
const saved = computed(() => savedIds.value.includes(id))
|
|
||||||
|
|
||||||
const waLink = computed(() => {
|
|
||||||
if (!product.value) return "#"
|
|
||||||
const text = encodeURIComponent(`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`)
|
|
||||||
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleSave() {
|
|
||||||
if (saved.value) {
|
|
||||||
savedIds.value = savedIds.value.filter((item) => item !== id)
|
|
||||||
} else {
|
|
||||||
savedIds.value.push(id)
|
|
||||||
}
|
|
||||||
setSavedProducts(savedIds.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProduct() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await fetchProduct(id)
|
|
||||||
product.value = data.product
|
|
||||||
} catch {
|
|
||||||
const fallbackProduct = defaultProducts.find((item) => Number(item.id) === id) || null
|
|
||||||
product.value = fallbackProduct ? { ...fallbackProduct, images: [fallbackProduct.image] } : null
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadProduct)
|
|
||||||
</script>
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SecondTech Market</title>
|
<title>SecondTech Market</title>
|
||||||
|
<link rel="icon" href="/round-logo-sija.png" type="image/png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+49
-1
@@ -271,7 +271,7 @@ app.get("/api/auth/me", auth, async (req, res, next) => {
|
|||||||
app.get("/api/products", async (req, res, next) => {
|
app.get("/api/products", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC"
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE status IS NULL OR status = 'Tersedia' ORDER BY created_at DESC, id DESC"
|
||||||
)
|
)
|
||||||
res.json({ products: await attachProductImages(rows) })
|
res.json({ products: await attachProductImages(rows) })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -365,6 +365,54 @@ app.patch("/api/products/:id/status", auth, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.put(
|
||||||
|
"/api/products/:id",
|
||||||
|
auth,
|
||||||
|
upload.fields([
|
||||||
|
{ name: "images", maxCount: maxImageCount },
|
||||||
|
{ name: "image", maxCount: maxImageCount },
|
||||||
|
{ name: "photos", maxCount: maxImageCount }
|
||||||
|
]),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
if (!product || product.user_id !== req.user.id) {
|
||||||
|
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
||||||
|
|
||||||
|
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description) {
|
||||||
|
res.status(400).json({ message: "Semua field produk wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE products SET title = ?, category = ?, price = ?, `condition` = ?, location = ?, seller = ?, whatsapp = ?, description = ? WHERE id = ? AND user_id = ?",
|
||||||
|
[title, category, Number(price), condition, location, seller, whatsapp, description, req.params.id, req.user.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const uploadedImages = getUploadedImages(req)
|
||||||
|
if (uploadedImages.length) {
|
||||||
|
await deleteUploadedImages(product)
|
||||||
|
await pool.query("DELETE FROM product_images WHERE product_id = ?", [req.params.id])
|
||||||
|
|
||||||
|
const mainImagePath = `/uploads/${uploadedImages[0].filename}`
|
||||||
|
await pool.query("UPDATE products SET image = ? WHERE id = ?", [mainImagePath, req.params.id])
|
||||||
|
|
||||||
|
const imageRows = uploadedImages.map((file, index) => [req.params.id, `/uploads/${file.filename}`, index])
|
||||||
|
await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows])
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedProduct = await findProductById(req.params.id)
|
||||||
|
res.json({ product: updatedProduct })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
app.delete("/api/products/:id", auth, async (req, res, next) => {
|
app.delete("/api/products/:id", auth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const product = await findProductById(req.params.id)
|
const product = await findProductById(req.params.id)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
@@ -9,6 +9,7 @@ import SavedView from "../views/SavedView.vue"
|
|||||||
import LoginView from "../views/LoginView.vue"
|
import LoginView from "../views/LoginView.vue"
|
||||||
import RegisterView from "../views/RegisterView.vue"
|
import RegisterView from "../views/RegisterView.vue"
|
||||||
import AdminProductsView from "../views/AdminProductsView.vue"
|
import AdminProductsView from "../views/AdminProductsView.vue"
|
||||||
|
import EditProductView from "../views/EditProductView.vue"
|
||||||
import { getCurrentUser, getToken } from "../utils"
|
import { getCurrentUser, getToken } from "../utils"
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -17,6 +18,7 @@ const routes = [
|
|||||||
{ path: "/produk/:id", name: "product-detail", component: ProductDetailView },
|
{ path: "/produk/:id", name: "product-detail", component: ProductDetailView },
|
||||||
{ path: "/jual", name: "sell-product", component: SellProductView, meta: { requiresAuth: true } },
|
{ path: "/jual", name: "sell-product", component: SellProductView, meta: { requiresAuth: true } },
|
||||||
{ path: "/dashboard", name: "dashboard", component: DashboardView, meta: { requiresAuth: true } },
|
{ path: "/dashboard", name: "dashboard", component: DashboardView, meta: { requiresAuth: true } },
|
||||||
|
{ path: "/edit/:id", name: "edit-product", component: EditProductView, meta: { requiresAuth: true } },
|
||||||
{ path: "/tersimpan", name: "saved", component: SavedView, meta: { requiresAuth: true } },
|
{ path: "/tersimpan", name: "saved", component: SavedView, meta: { requiresAuth: true } },
|
||||||
{
|
{
|
||||||
path: "/admin/products",
|
path: "/admin/products",
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ export function createProduct(formData) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateProduct(id, formData) {
|
||||||
|
return requestJson(`/api/products/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function updateProductStatus(id, status) {
|
export function updateProductStatus(id, status) {
|
||||||
return requestJson(`/api/products/${id}/status`, {
|
return requestJson(`/api/products/${id}/status`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@@ -51,7 +51,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2 md:flex-col">
|
<div class="flex flex-wrap gap-2 md:flex-col">
|
||||||
<RouterLink :to="`/produk/${product.id}`" class="btn-secondary !px-3 !py-2">Detail</RouterLink>
|
<RouterLink :to="`/produk/${product.id}`" class="btn-secondary !px-3 !py-2">Detail</RouterLink>
|
||||||
<button class="btn-secondary !px-3 !py-2" @click="markSold(product.id)">Tandai Terjual</button>
|
<RouterLink :to="`/edit/${product.id}`" class="btn-secondary !px-3 !py-2">Edit</RouterLink>
|
||||||
|
<button class="btn-secondary !px-3 !py-2" @click="toggleSold(product.id, product.status)">
|
||||||
|
{{ product.status === "Terjual" ? "Batalkan Terjual" : "Tandai Terjual" }}
|
||||||
|
</button>
|
||||||
<button class="rounded-xl bg-red-50 px-3 py-2 text-sm font-bold text-red-700 hover:bg-red-100" @click="deleteProduct(product.id)">Hapus</button>
|
<button class="rounded-xl bg-red-50 px-3 py-2 text-sm font-bold text-red-700 hover:bg-red-100" @click="deleteProduct(product.id)">Hapus</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,9 +96,10 @@ async function loadProducts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markSold(id) {
|
async function toggleSold(id, currentStatus) {
|
||||||
try {
|
try {
|
||||||
const data = await updateProductStatus(id, "Terjual")
|
const newStatus = currentStatus === "Terjual" ? "Tersedia" : "Terjual"
|
||||||
|
const data = await updateProductStatus(id, newStatus)
|
||||||
userProducts.value = userProducts.value.map((product) => {
|
userProducts.value = userProducts.value.map((product) => {
|
||||||
if (product.id === id) return data.product
|
if (product.id === id) return data.product
|
||||||
return product
|
return product
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container-page py-10">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-extrabold">Edit Barang</h1>
|
||||||
|
<p class="mt-2 text-slate-600">
|
||||||
|
Ubah informasi barang yang sudah kamu posting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingProduct" class="card p-10 text-center">
|
||||||
|
<h2 class="text-xl font-extrabold">Memuat data barang...</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="notFound" class="card p-10 text-center">
|
||||||
|
<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>
|
||||||
|
<RouterLink to="/dashboard" class="btn-primary mt-5"
|
||||||
|
>Kembali ke Dashboard</RouterLink
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-8 lg:grid-cols-[1fr_360px]">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Nama Barang</label>
|
||||||
|
<input
|
||||||
|
v-model="form.title"
|
||||||
|
class="input mt-2"
|
||||||
|
placeholder="Contoh: Laptop ThinkPad T480"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="label">Kategori</label>
|
||||||
|
<select v-model="form.category" class="input mt-2" required>
|
||||||
|
<option v-for="cat in realCategories" :key="cat" :value="cat">
|
||||||
|
{{ cat }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Harga</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.price"
|
||||||
|
type="number"
|
||||||
|
class="input mt-2"
|
||||||
|
placeholder="2500000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="label">Kondisi</label>
|
||||||
|
<select v-model="form.condition" class="input mt-2">
|
||||||
|
<option>Bekas Normal</option>
|
||||||
|
<option>Bekas Mulus</option>
|
||||||
|
<option>Bekas Minus</option>
|
||||||
|
<option>Bekas Server Room</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Lokasi</label>
|
||||||
|
<input
|
||||||
|
v-model="form.location"
|
||||||
|
class="input mt-2"
|
||||||
|
placeholder="Yogyakarta"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="label">Nama Penjual</label>
|
||||||
|
<input
|
||||||
|
v-model="form.seller"
|
||||||
|
class="input mt-2"
|
||||||
|
placeholder="Nama kamu"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Nomor WhatsApp +62</label>
|
||||||
|
<input
|
||||||
|
v-model="form.whatsapp"
|
||||||
|
class="input mt-2"
|
||||||
|
placeholder="+6281234567890"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Foto Barang</label>
|
||||||
|
<p v-if="existingImages.length" class="mb-3 text-sm text-slate-500">
|
||||||
|
{{ existingImages.length }} foto saat ini. Upload foto baru jika
|
||||||
|
ingin mengganti.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
class="input mt-2"
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
<span>{{ selectedImages.length }} foto baru dipilih</span>
|
||||||
|
<button type="button" class="text-red-700" @click="clearImages">
|
||||||
|
Hapus semua
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="previewUrls.length"
|
||||||
|
class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-xs text-slate-500">
|
||||||
|
Maksimal 6 foto, 10 MB per foto. Biarkan kosong jika tidak ingin
|
||||||
|
mengganti foto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Deskripsi</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
class="input mt-2 min-h-32"
|
||||||
|
placeholder="Jelaskan kondisi barang..."
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-primary w-full" :disabled="loading">
|
||||||
|
{{ loading ? 'Menyimpan...' : 'Simpan Perubahan' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card h-fit p-6">
|
||||||
|
<h2 class="text-lg font-extrabold">Tips Edit Produk</h2>
|
||||||
|
<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>- Upload foto baru jika kondisi fisik barang berubah.</li>
|
||||||
|
<li>- Pastikan nomor WhatsApp masih aktif.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, computed, ref, onMounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { categories } from '../data/products';
|
||||||
|
import { fetchProduct, updateProduct } from '../utils';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const realCategories = computed(() =>
|
||||||
|
categories.filter((cat) => cat !== 'Semua')
|
||||||
|
);
|
||||||
|
const fileInput = ref(null);
|
||||||
|
const MAX_IMAGES = 6;
|
||||||
|
const MAX_IMAGE_SIZE_MB = 10;
|
||||||
|
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024;
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
category: 'Laptop',
|
||||||
|
price: '',
|
||||||
|
condition: 'Bekas Normal',
|
||||||
|
location: '',
|
||||||
|
seller: '',
|
||||||
|
whatsapp: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
const existingImages = ref([]);
|
||||||
|
const selectedImages = ref([]);
|
||||||
|
const previewUrls = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadingProduct = ref(true);
|
||||||
|
const notFound = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
function imageKey(file) {
|
||||||
|
return `${file.name}-${file.size}-${file.lastModified}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildPreviews() {
|
||||||
|
previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url));
|
||||||
|
previewUrls.value = selectedImages.value.map((file) => ({
|
||||||
|
key: imageKey(file),
|
||||||
|
url: URL.createObjectURL(file),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageChange(event) {
|
||||||
|
const incomingFiles = Array.from(event.target.files || []);
|
||||||
|
const oversizedFile = incomingFiles.find(
|
||||||
|
(file) => file.size > MAX_IMAGE_SIZE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (oversizedFile) {
|
||||||
|
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`;
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = [...selectedImages.value];
|
||||||
|
incomingFiles.forEach((file) => {
|
||||||
|
if (
|
||||||
|
!merged.some((item) => imageKey(item) === imageKey(file)) &&
|
||||||
|
merged.length < MAX_IMAGES
|
||||||
|
) {
|
||||||
|
merged.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedImages.value = merged;
|
||||||
|
rebuildPreviews();
|
||||||
|
errorMessage.value = '';
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(index) {
|
||||||
|
selectedImages.value = selectedImages.value.filter(
|
||||||
|
(_, itemIndex) => itemIndex !== index
|
||||||
|
);
|
||||||
|
rebuildPreviews();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImages() {
|
||||||
|
selectedImages.value = [];
|
||||||
|
rebuildPreviews();
|
||||||
|
if (fileInput.value) fileInput.value.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit() {
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
|
||||||
|
const payload = new FormData();
|
||||||
|
Object.entries(form).forEach(([key, value]) => {
|
||||||
|
payload.append(key, value);
|
||||||
|
});
|
||||||
|
selectedImages.value.forEach((image) => {
|
||||||
|
payload.append('images', image);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProduct(route.params.id, payload);
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchProduct(route.params.id);
|
||||||
|
const product = data.product;
|
||||||
|
|
||||||
|
form.title = product.title;
|
||||||
|
form.category = product.category;
|
||||||
|
form.price = product.price;
|
||||||
|
form.condition = product.condition;
|
||||||
|
form.location = product.location;
|
||||||
|
form.seller = product.seller;
|
||||||
|
form.whatsapp = product.whatsapp;
|
||||||
|
form.description = product.description;
|
||||||
|
|
||||||
|
if (product.images && product.images.length) {
|
||||||
|
existingImages.value = product.images;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notFound.value = true;
|
||||||
|
} finally {
|
||||||
|
loadingProduct.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -17,6 +17,10 @@
|
|||||||
<input v-model="password" type="password" class="input mt-2" placeholder="••••••••" required />
|
<input v-model="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">
|
||||||
|
<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>
|
||||||
{{ loading ? "Memproses..." : "Masuk" }}
|
{{ loading ? "Memproses..." : "Masuk" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ const filteredProducts = computed(() => {
|
|||||||
const matchCondition =
|
const matchCondition =
|
||||||
selectedCondition.value === "Semua" || product.condition === selectedCondition.value
|
selectedCondition.value === "Semua" || product.condition === selectedCondition.value
|
||||||
|
|
||||||
return matchSearch && matchCategory && matchCondition
|
const matchStatus = product.status === "Tersedia" || !product.status
|
||||||
|
|
||||||
|
return matchSearch && matchCategory && matchCondition && matchStatus
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+76
-26
@@ -2,69 +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">
|
||||||
{{ loading ? "Memproses..." : "Daftar" }}
|
<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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{{ 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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user