menambahkan fitur unutk barang saya
This commit is contained in:
+49
-1
@@ -271,7 +271,7 @@ app.get("/api/auth/me", auth, async (req, res, next) => {
|
||||
app.get("/api/products", async (req, res, next) => {
|
||||
try {
|
||||
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) })
|
||||
} 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) => {
|
||||
try {
|
||||
const product = await findProductById(req.params.id)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
@@ -9,6 +9,7 @@ import SavedView from "../views/SavedView.vue"
|
||||
import LoginView from "../views/LoginView.vue"
|
||||
import RegisterView from "../views/RegisterView.vue"
|
||||
import AdminProductsView from "../views/AdminProductsView.vue"
|
||||
import EditProductView from "../views/EditProductView.vue"
|
||||
import { getCurrentUser, getToken } from "../utils"
|
||||
|
||||
const routes = [
|
||||
@@ -17,6 +18,7 @@ const routes = [
|
||||
{ path: "/produk/:id", name: "product-detail", component: ProductDetailView },
|
||||
{ path: "/jual", name: "sell-product", component: SellProductView, 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: "/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) {
|
||||
return requestJson(`/api/products/${id}/status`, {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -51,7 +51,10 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 md:flex-col">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,9 +96,10 @@ async function loadProducts() {
|
||||
}
|
||||
}
|
||||
|
||||
async function markSold(id) {
|
||||
async function toggleSold(id, currentStatus) {
|
||||
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) => {
|
||||
if (product.id === id) return data.product
|
||||
return product
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<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</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>
|
||||
@@ -95,7 +95,9 @@ const filteredProducts = computed(() => {
|
||||
const matchCondition =
|
||||
selectedCondition.value === "Semua" || product.condition === selectedCondition.value
|
||||
|
||||
return matchSearch && matchCategory && matchCondition
|
||||
const matchStatus = product.status === "Tersedia" || !product.status
|
||||
|
||||
return matchSearch && matchCategory && matchCondition && matchStatus
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user