Compare commits
9 Commits
b20b46aa0f
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 90b925a0f5 | |||
| 967a3b7945 | |||
| 3bb305f3a2 | |||
| 8620a3ba96 | |||
| 213930291e | |||
| 0168395347 | |||
| 08821eaff0 | |||
| 0c54b91ed2 | |||
| f0dd99b330 |
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SecondTech Market</title>
|
||||
<link rel="icon" href="/round-logo-sija.png" type="image/png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
|
After Width: | Height: | Size: 320 KiB |
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 990 KiB |
|
Before Width: | Height: | Size: 826 KiB After Width: | Height: | Size: 826 KiB |
|
Before Width: | Height: | Size: 506 KiB After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 191 KiB |
@@ -4,9 +4,7 @@
|
||||
<div class="grid gap-8 md:grid-cols-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-extrabold">SecondTech Market</h2>
|
||||
<p class="mt-3 text-sm leading-6 text-slate-300">
|
||||
Marketplace barang bekas teknologi untuk laptop, PC, monitor, keyboard, perangkat jaringan, dan server.
|
||||
</p>
|
||||
<p class="mt-3 text-sm leading-6 text-slate-300">Marketplace barang bekas teknologi untuk laptop, PC, monitor, keyboard, perangkat jaringan, dan server.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold">Navigasi</h3>
|
||||
@@ -19,11 +17,12 @@
|
||||
<div>
|
||||
<h3 class="font-bold">Catatan Project</h3>
|
||||
<p class="mt-3 text-sm leading-6 text-slate-300">
|
||||
Versi ini memakai backend Express, database MySQL, dan upload foto produk dari file perangkat.
|
||||
Project ini di buat unutk komunitas jual beli barang bekas teknologi, dengan fokus pada perangkat jaringan dan server. Dibuat menggunakan Vue.js, Tailwind CSS, dan Firebase untuk autentikasi dan database. Fitur utama meliputi
|
||||
listing produk, dashboard pengguna, dan admin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-10 text-xs text-slate-400">© 2026 SecondTech Market. Dibuat untuk project web.</p>
|
||||
<p class="mt-10 text-center text-xs text-slate-400">© 2026 SecondTech Market. Dibuat untuk project web.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||
<div class="container-page flex h-16 items-center justify-between">
|
||||
<RouterLink to="/" class="flex items-center gap-2">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-600 text-white">
|
||||
<Cpu size="20" />
|
||||
</div>
|
||||
<img src="/round-logo-sija.png" alt="SecondTech logo" class="h-9 w-9 rounded-full object-cover" />
|
||||
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
||||
</RouterLink>
|
||||
|
||||
@@ -52,7 +50,7 @@
|
||||
<button type="button" class="btn-secondary mt-2 !py-2 text-center" @click="toggleTheme">
|
||||
<Sun v-if="darkMode" size="18" />
|
||||
<Moon v-else size="18" />
|
||||
{{ darkMode ? "Light Mode" : "Dark Mode" }}
|
||||
{{ darkMode ? 'Light Mode' : 'Dark Mode' }}
|
||||
</button>
|
||||
|
||||
<div v-if="currentUser" class="mt-2 grid gap-2">
|
||||
@@ -74,60 +72,60 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { Cpu, Menu, Moon, Sun } from "lucide-vue-next"
|
||||
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Menu, Moon, Sun } from 'lucide-vue-next';
|
||||
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from '../utils';
|
||||
|
||||
const open = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const currentUser = ref(getCurrentUser())
|
||||
const darkMode = ref(localStorage.getItem("secondtech_theme") === "dark")
|
||||
const open = ref(false);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const currentUser = ref(getCurrentUser());
|
||||
const darkMode = ref(localStorage.getItem('secondtech_theme') === 'dark');
|
||||
|
||||
const isSuperadmin = computed(() => currentUser.value?.role === "superadmin")
|
||||
const isSuperadmin = computed(() => currentUser.value?.role === 'superadmin');
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
currentUser.value = getCurrentUser()
|
||||
}
|
||||
)
|
||||
currentUser.value = getCurrentUser();
|
||||
},
|
||||
);
|
||||
|
||||
function applyTheme() {
|
||||
document.documentElement.classList.toggle("dark", darkMode.value)
|
||||
localStorage.setItem("secondtech_theme", darkMode.value ? "dark" : "light")
|
||||
document.documentElement.classList.toggle('dark', darkMode.value);
|
||||
localStorage.setItem('secondtech_theme', darkMode.value ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
darkMode.value = !darkMode.value
|
||||
applyTheme()
|
||||
darkMode.value = !darkMode.value;
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
async function refreshCurrentUser() {
|
||||
if (!getToken()) return
|
||||
if (!getToken()) return;
|
||||
|
||||
try {
|
||||
const data = await fetchCurrentUser()
|
||||
setAuth({ token: getToken(), user: data.user })
|
||||
currentUser.value = getCurrentUser()
|
||||
const data = await fetchCurrentUser();
|
||||
setAuth({ token: getToken(), user: data.user });
|
||||
currentUser.value = getCurrentUser();
|
||||
} catch {
|
||||
clearAuth()
|
||||
currentUser.value = null
|
||||
clearAuth();
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearAuth()
|
||||
currentUser.value = null
|
||||
open.value = false
|
||||
router.push("/login")
|
||||
clearAuth();
|
||||
currentUser.value = null;
|
||||
open.value = false;
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyTheme()
|
||||
refreshCurrentUser()
|
||||
})
|
||||
applyTheme();
|
||||
refreshCurrentUser();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,101 +1,4 @@
|
||||
export const products = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Laptop Lenovo ThinkPad T480",
|
||||
category: "Laptop",
|
||||
price: 3200000,
|
||||
condition: "Bekas Normal",
|
||||
location: "Yogyakarta",
|
||||
seller: "Raka SIJA",
|
||||
whatsapp: "6281234567890",
|
||||
image: "https://images.unsplash.com/photo-1496181133206-80ce9b88a853?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Laptop bekas cocok untuk belajar coding, jaringan, virtual machine ringan, dan kebutuhan sekolah. RAM 8GB, SSD 256GB, keyboard normal."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "PC Rakitan i5 Gen 8",
|
||||
category: "PC",
|
||||
price: 4100000,
|
||||
condition: "Bekas Normal",
|
||||
location: "Bantul",
|
||||
seller: "Dimas Tech",
|
||||
whatsapp: "628111222333",
|
||||
image: "https://images.unsplash.com/photo-1587202372775-e229f172b9d7?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "PC rakitan untuk lab jaringan, desain ringan, dan multitasking. Intel Core i5, RAM 16GB, SSD 512GB."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Monitor LG 24 Inch IPS",
|
||||
category: "Monitor",
|
||||
price: 1150000,
|
||||
condition: "Bekas Mulus",
|
||||
location: "Sleman",
|
||||
seller: "Adit Hardware",
|
||||
whatsapp: "628555666777",
|
||||
image: "https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Monitor IPS 24 inch, warna masih bagus, cocok untuk coding dan editing. Include kabel power dan HDMI."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Keyboard Mechanical Keychron K2",
|
||||
category: "Keyboard",
|
||||
price: 850000,
|
||||
condition: "Bekas Normal",
|
||||
location: "Solo",
|
||||
seller: "Naufal Keys",
|
||||
whatsapp: "628333444555",
|
||||
image: "https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Keyboard mechanical wireless, switch brown, cocok untuk coding. Kondisi normal dan keycap lengkap."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "MikroTik RB941 hAP Lite",
|
||||
category: "Router",
|
||||
price: 180000,
|
||||
condition: "Bekas Normal",
|
||||
location: "Kulon Progo",
|
||||
seller: "Lab Network",
|
||||
whatsapp: "628777888999",
|
||||
image: "https://images.unsplash.com/photo-1606904825846-647eb07f5be2?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Router MikroTik untuk belajar routing, firewall, DHCP, hotspot, dan konfigurasi dasar jaringan."
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Switch TP-Link 8 Port Gigabit",
|
||||
category: "Switch",
|
||||
price: 250000,
|
||||
condition: "Bekas Normal",
|
||||
location: "Magelang",
|
||||
seller: "Fajar Net",
|
||||
whatsapp: "628999111222",
|
||||
image: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Switch 8 port gigabit, cocok untuk lab kecil, warnet mini, dan praktik jaringan lokal."
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Server Dell PowerEdge R620",
|
||||
category: "Server",
|
||||
price: 6500000,
|
||||
condition: "Bekas Server Room",
|
||||
location: "Jakarta",
|
||||
seller: "Server Bekas ID",
|
||||
whatsapp: "628222333444",
|
||||
image: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Server rackmount bekas data center, cocok untuk belajar virtualization, Proxmox, Docker, dan homelab."
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "Access Point TP-Link EAP225",
|
||||
category: "Access Point",
|
||||
price: 620000,
|
||||
condition: "Bekas Mulus",
|
||||
location: "Semarang",
|
||||
seller: "WiFi Store",
|
||||
whatsapp: "6281212121212",
|
||||
image: "https://images.unsplash.com/photo-1544197150-b99a580bb7a8?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Access point ceiling untuk hotspot sekolah, kantor, dan lab jaringan. Kondisi normal."
|
||||
}
|
||||
]
|
||||
export const products = []
|
||||
|
||||
export const categories = [
|
||||
"Semua",
|
||||
|
||||
@@ -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,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>
|
||||
@@ -14,6 +14,7 @@
|
||||
:pauseOnHover="true"
|
||||
:yoyo="true"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<TextType
|
||||
as="h2"
|
||||
@@ -26,7 +27,11 @@
|
||||
cursorCharacter="|"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-6 max-w-xl text-lg leading-8 text-slate-600">Temukan laptop bekas, PC, monitor, keyboard, router, switch, access point, dan server bekas untuk berbagai kebutuhan.</p>
|
||||
|
||||
<p class="mt-6 max-w-xl text-lg leading-8 text-slate-600">
|
||||
Temukan laptop bekas, PC, monitor, keyboard, router, switch, access point, dan server bekas untuk berbagai kebutuhan.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<RouterLink to="/jual" class="btn-primary">Jual Barang Sekarang</RouterLink>
|
||||
<RouterLink to="/marketplace" class="btn-secondary">Lihat Marketplace</RouterLink>
|
||||
@@ -35,7 +40,11 @@
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-4 rounded-[2rem] bg-brand-100 blur-2xl"></div>
|
||||
<img class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl" src="https://images.unsplash.com/photo-1516321318423-f06f85e504b3?q=80&w=1200&auto=format&fit=crop" alt="Tech marketplace" />
|
||||
<img
|
||||
class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl"
|
||||
src="/Hero.jpeg"
|
||||
alt="Tech marketplace"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -46,11 +55,17 @@
|
||||
<h2 class="text-2xl font-extrabold">Kategori Populer</h2>
|
||||
<p class="mt-2 text-slate-600">Fokus ke perangkat teknologi dan jaringan.</p>
|
||||
</div>
|
||||
<RouterLink to="/marketplace" class="text-sm font-bold text-brand-700">Lihat semua</RouterLink>
|
||||
<RouterLink to="/marketplace" class="text-sm font-bold text-brand-700">
|
||||
Lihat semua
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<div v-for="cat in categoryList" :key="cat.name" class="card p-5 text-center transition hover:-translate-y-1 hover:shadow-md">
|
||||
<div
|
||||
v-for="cat in categoryList"
|
||||
:key="cat.name"
|
||||
class="card p-5 text-center transition hover:-translate-y-1 hover:shadow-md"
|
||||
>
|
||||
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-700">
|
||||
<component :is="iconMap[cat.icon]" :size="22" />
|
||||
</div>
|
||||
@@ -65,32 +80,56 @@
|
||||
<div class="mb-8 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-extrabold">Produk Terbaru</h2>
|
||||
<p class="mt-2 text-slate-600">Contoh barang bekas teknologi yang tersedia.</p>
|
||||
<p class="mt-2 text-slate-600">Barang terbaru yang tersedia di marketplace.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<ProductCard v-for="product in latestProducts" :key="product.id" :product="product" :saved="saved.includes(product.id)" @toggle-save="toggleSave" />
|
||||
<div v-if="latestProducts.length" class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<ProductCard
|
||||
v-for="product in latestProducts"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
:saved="saved.includes(product.id)"
|
||||
@toggle-save="toggleSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="card p-10 text-center">
|
||||
<h3 class="text-xl font-extrabold">Belum ada produk</h3>
|
||||
<p class="mt-2 text-slate-600">Produk terbaru akan muncul setelah barang diposting.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import ProductCard from '../components/ProductCard.vue';
|
||||
import { getSavedProducts, setSavedProducts } from '../utils';
|
||||
import { Boxes, Laptop, PcCase, Monitor, Keyboard, Router, Network, Server, Wifi } from 'lucide-vue-next';
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import { Boxes, Keyboard, Laptop, Monitor, Network, PcCase, Router, Server, Wifi } from "lucide-vue-next"
|
||||
import ProductCard from "../components/ProductCard.vue"
|
||||
import { categoryOptions, products as defaultProducts } from "../data/products"
|
||||
import { fetchProducts, getSavedProducts, setSavedProducts } from "../utils"
|
||||
import ShinyText from "../vuebits/ShinyText/ShinyText.vue"
|
||||
import TextType from "../vuebits/TextType/TextType.vue"
|
||||
|
||||
import { products, categoryOptions } from '../data/products';
|
||||
import ShinyText from '../vuebits/ShinyText/ShinyText.vue';
|
||||
import TextType from '../vuebits/TextType/TextType.vue';
|
||||
const saved = ref(getSavedProducts())
|
||||
const products = ref(defaultProducts)
|
||||
|
||||
const saved = ref(getSavedProducts());
|
||||
const categoryList = categoryOptions
|
||||
.filter((category) => category.name !== 'Semua')
|
||||
.slice(0, 6);
|
||||
const latestProducts = computed(() => products.slice(0, 4));
|
||||
.filter((category) => category.name !== "Semua")
|
||||
.slice(0, 6)
|
||||
|
||||
const latestProducts = computed(() => {
|
||||
return [...products.value]
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.created_at || 0).getTime()
|
||||
const dateB = new Date(b.created_at || 0).getTime()
|
||||
|
||||
if (dateA !== dateB) return dateB - dateA
|
||||
return Number(b.id) - Number(a.id)
|
||||
})
|
||||
.slice(0, 4)
|
||||
})
|
||||
|
||||
const iconMap = {
|
||||
Boxes,
|
||||
Laptop,
|
||||
@@ -100,16 +139,27 @@ const iconMap = {
|
||||
Router,
|
||||
Network,
|
||||
Server,
|
||||
Wifi,
|
||||
};
|
||||
Wifi
|
||||
}
|
||||
|
||||
function toggleSave(id) {
|
||||
if (saved.value.includes(id)) {
|
||||
saved.value = saved.value.filter((item) => item !== id);
|
||||
saved.value = saved.value.filter((item) => item !== id)
|
||||
} else {
|
||||
saved.value.push(id);
|
||||
saved.value.push(id)
|
||||
}
|
||||
setSavedProducts(saved.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
setSavedProducts(saved.value)
|
||||
}
|
||||
|
||||
async function loadLatestProducts() {
|
||||
try {
|
||||
const data = await fetchProducts()
|
||||
products.value = data.products?.length ? data.products : defaultProducts
|
||||
} catch {
|
||||
products.value = defaultProducts
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadLatestProducts)
|
||||
</script>
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
<input v-model="password" type="password" class="input mt-2" placeholder="••••••••" required />
|
||||
</div>
|
||||
<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" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<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
|
||||
← 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-2">
|
||||
<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:max-h-[520px] sm:overflow-y-auto">
|
||||
<div v-else-if="product" class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(380px,520px)]">
|
||||
<div class="grid items-start gap-4 sm:grid-cols-[88px_minmax(0,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}`"
|
||||
@@ -19,12 +19,20 @@
|
||||
: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" />
|
||||
<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">
|
||||
<img :src="getProductImageUrl(selectedImage)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
|
||||
<div class="order-1 sm:order-2">
|
||||
<img
|
||||
:src="getProductImageUrl(selectedImage)"
|
||||
:alt="product.title"
|
||||
class="block h-auto max-h-[78vh] w-auto max-w-full rounded-2xl object-contain shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,22 +40,31 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -56,11 +73,16 @@
|
||||
|
||||
<div class="mt-6">
|
||||
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
||||
<p class="mt-2 leading-7 text-slate-600">{{ product.description }}</p>
|
||||
<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>
|
||||
<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>
|
||||
@@ -70,7 +92,9 @@
|
||||
|
||||
<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>
|
||||
<RouterLink to="/marketplace" class="btn-primary mt-5">
|
||||
Lihat Marketplace
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -79,17 +103,28 @@
|
||||
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"
|
||||
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]
|
||||
|
||||
const images = product.value.images?.length
|
||||
? product.value.images
|
||||
: [product.value.image]
|
||||
|
||||
return [...new Set(images.filter(Boolean))]
|
||||
})
|
||||
|
||||
@@ -104,11 +139,18 @@ watch(
|
||||
)
|
||||
|
||||
const savedIds = ref(getSavedProducts())
|
||||
const saved = computed(() => savedIds.value.includes(id))
|
||||
|
||||
const saved = computed(() => {
|
||||
return savedIds.value.includes(id)
|
||||
})
|
||||
|
||||
const waLink = computed(() => {
|
||||
if (!product.value) return "#"
|
||||
const text = encodeURIComponent(`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`)
|
||||
|
||||
const text = encodeURIComponent(
|
||||
`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`
|
||||
)
|
||||
|
||||
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
||||
})
|
||||
|
||||
@@ -118,17 +160,22 @@ function toggleSave() {
|
||||
} 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
|
||||
|
||||
product.value = fallbackProduct
|
||||
? { ...fallbackProduct, images: [fallbackProduct.image] }
|
||||
: null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -2,69 +2,119 @@
|
||||
<section class="container-page grid min-h-[70vh] place-items-center py-10">
|
||||
<div class="card w-full max-w-md p-6">
|
||||
<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">
|
||||
<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 }}
|
||||
</p>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
|
||||
<p class="mt-5 text-center text-sm text-slate-600">
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { registerUser, setAuth } from "../utils"
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { registerUser, setAuth } from '../utils';
|
||||
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
email: "",
|
||||
whatsapp: "",
|
||||
password: ""
|
||||
})
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref("")
|
||||
name: '',
|
||||
email: '',
|
||||
whatsapp: '',
|
||||
password: '',
|
||||
});
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
async function register() {
|
||||
loading.value = true
|
||||
errorMessage.value = ""
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const data = await registerUser(form)
|
||||
setAuth(data)
|
||||
router.push("/dashboard")
|
||||
const data = await registerUser(form);
|
||||
setAuth(data);
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message
|
||||
errorMessage.value = error.message;
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,30 +2,49 @@
|
||||
<section class="container-page py-10">
|
||||
<div class="mb-8">
|
||||
<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 class="grid gap-8 lg:grid-cols-[1fr_360px]">
|
||||
<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 }}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<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 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>
|
||||
<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 />
|
||||
<input
|
||||
v-model.number="form.price"
|
||||
type="number"
|
||||
class="input mt-2"
|
||||
placeholder="2500000"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,18 +60,33 @@
|
||||
</div>
|
||||
<div>
|
||||
<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 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 />
|
||||
<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 />
|
||||
<label class="label">Nomor WhatsApp +62</label>
|
||||
<input
|
||||
v-model="form.whatsapp"
|
||||
class="input mt-2"
|
||||
placeholder="+6281234567890"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,39 +101,67 @@
|
||||
@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>
|
||||
<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 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)">
|
||||
<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. 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>
|
||||
</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>
|
||||
<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 ? "Memposting..." : "Posting Barang" }}
|
||||
{{ loading ? 'Memposting...' : 'Posting Barang' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="card h-fit p-6">
|
||||
<h2 class="text-lg font-extrabold">Tips Isi Produk</h2>
|
||||
<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>- Pakai nomor WhatsApp aktif.</li>
|
||||
<li>- Upload beberapa foto dari sisi yang berbeda.</li>
|
||||
@@ -110,109 +172,118 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, computed, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { categories } from "../data/products"
|
||||
import { createProduct, getCurrentUser } from "../utils"
|
||||
import { reactive, computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { categories } from '../data/products';
|
||||
import { createProduct, getCurrentUser } from '../utils';
|
||||
|
||||
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 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 selectedImages = ref([])
|
||||
const previewUrls = ref([])
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref("")
|
||||
title: '',
|
||||
category: 'Laptop',
|
||||
price: '',
|
||||
condition: 'Bekas Normal',
|
||||
location: '',
|
||||
seller: '',
|
||||
whatsapp: '',
|
||||
description: '',
|
||||
});
|
||||
const selectedImages = ref([]);
|
||||
const previewUrls = ref([]);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
const currentUser = getCurrentUser()
|
||||
const currentUser = getCurrentUser();
|
||||
if (currentUser) {
|
||||
form.seller = currentUser.name || ""
|
||||
form.whatsapp = currentUser.whatsapp || ""
|
||||
form.seller = currentUser.name || '';
|
||||
form.whatsapp = currentUser.whatsapp || '';
|
||||
}
|
||||
|
||||
function imageKey(file) {
|
||||
return `${file.name}-${file.size}-${file.lastModified}`
|
||||
return `${file.name}-${file.size}-${file.lastModified}`;
|
||||
}
|
||||
|
||||
function rebuildPreviews() {
|
||||
previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url))
|
||||
previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url));
|
||||
previewUrls.value = selectedImages.value.map((file) => ({
|
||||
key: imageKey(file),
|
||||
url: URL.createObjectURL(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)
|
||||
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
|
||||
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]
|
||||
const merged = [...selectedImages.value];
|
||||
incomingFiles.forEach((file) => {
|
||||
if (!merged.some((item) => imageKey(item) === imageKey(file)) && merged.length < MAX_IMAGES) {
|
||||
merged.push(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 = ""
|
||||
selectedImages.value = merged;
|
||||
rebuildPreviews();
|
||||
errorMessage.value = '';
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
function removeImage(index) {
|
||||
selectedImages.value = selectedImages.value.filter((_, itemIndex) => itemIndex !== index)
|
||||
rebuildPreviews()
|
||||
selectedImages.value = selectedImages.value.filter(
|
||||
(_, itemIndex) => itemIndex !== index
|
||||
);
|
||||
rebuildPreviews();
|
||||
}
|
||||
|
||||
function clearImages() {
|
||||
selectedImages.value = []
|
||||
rebuildPreviews()
|
||||
if (fileInput.value) fileInput.value.value = ""
|
||||
selectedImages.value = [];
|
||||
rebuildPreviews();
|
||||
if (fileInput.value) fileInput.value.value = '';
|
||||
}
|
||||
|
||||
async function submitProduct() {
|
||||
if (!selectedImages.value.length) {
|
||||
errorMessage.value = "Minimal 1 foto barang wajib diupload."
|
||||
return
|
||||
errorMessage.value = 'Minimal 1 foto barang wajib diupload.';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ""
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
const payload = new FormData()
|
||||
const payload = new FormData();
|
||||
Object.entries(form).forEach(([key, value]) => {
|
||||
payload.append(key, value)
|
||||
})
|
||||
payload.append(key, value);
|
||||
});
|
||||
selectedImages.value.forEach((image) => {
|
||||
payload.append("images", image)
|
||||
})
|
||||
payload.append('images', image);
|
||||
});
|
||||
|
||||
try {
|
||||
await createProduct(payload)
|
||||
router.push("/dashboard")
|
||||
await createProduct(payload);
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message
|
||||
errorMessage.value = error.message;
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||