fitur fitur darkmode dan foto foto
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import bcrypt from "bcryptjs"
|
||||
import cors from "cors"
|
||||
import dotenv from "dotenv"
|
||||
import express from "express"
|
||||
import fs from "fs"
|
||||
import jwt from "jsonwebtoken"
|
||||
import multer from "multer"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { pool } from "./db.js"
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const app = express()
|
||||
const port = Number(process.env.PORT || 5000)
|
||||
const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret"
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const uploadsDir = path.join(__dirname, "uploads")
|
||||
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true })
|
||||
}
|
||||
|
||||
app.use(cors({
|
||||
origin: process.env.CLIENT_URL || "http://localhost:5173",
|
||||
credentials: true
|
||||
}))
|
||||
app.use(express.json())
|
||||
app.use("/uploads", express.static(uploadsDir))
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: uploadsDir,
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`)
|
||||
}
|
||||
})
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 3 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.mimetype.startsWith("image/")) {
|
||||
cb(new Error("File harus berupa gambar."))
|
||||
return
|
||||
}
|
||||
cb(null, true)
|
||||
}
|
||||
})
|
||||
|
||||
function signUser(user) {
|
||||
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
|
||||
}
|
||||
|
||||
function publicUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
whatsapp: user.whatsapp,
|
||||
role: user.role || "user"
|
||||
}
|
||||
}
|
||||
|
||||
function auth(req, res, next) {
|
||||
const header = req.headers.authorization || ""
|
||||
const token = header.startsWith("Bearer ") ? header.slice(7) : ""
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ message: "Silakan login terlebih dahulu." })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
req.user = jwt.verify(token, jwtSecret)
|
||||
next()
|
||||
} catch {
|
||||
res.status(401).json({ message: "Sesi login tidak valid." })
|
||||
}
|
||||
}
|
||||
|
||||
async function superadminOnly(req, res, next) {
|
||||
try {
|
||||
const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id])
|
||||
|
||||
if (!rows[0] || rows[0].role !== "superadmin") {
|
||||
res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getProductImages(productId, fallbackImage) {
|
||||
const [images] = await pool.query(
|
||||
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
|
||||
[productId]
|
||||
)
|
||||
|
||||
return images.length ? images.map((item) => item.image) : [fallbackImage]
|
||||
}
|
||||
|
||||
async function attachProductImages(products) {
|
||||
return Promise.all(
|
||||
products.map(async (product) => ({
|
||||
...product,
|
||||
images: await getProductImages(product.id, product.image)
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
async function findProductById(id) {
|
||||
const [rows] = await pool.query(
|
||||
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?",
|
||||
[id]
|
||||
)
|
||||
|
||||
const product = rows[0]
|
||||
if (!product) return null
|
||||
|
||||
product.images = await getProductImages(product.id, product.image)
|
||||
return product
|
||||
}
|
||||
|
||||
async function deleteUploadedImages(product) {
|
||||
const images = product.images?.length ? product.images : [product.image]
|
||||
|
||||
images.forEach((image) => {
|
||||
if (image?.startsWith("/uploads/")) {
|
||||
fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app.get("/api/health", async (req, res, next) => {
|
||||
try {
|
||||
await pool.query("SELECT 1")
|
||||
res.json({ status: "ok" })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/api/auth/register", async (req, res, next) => {
|
||||
try {
|
||||
const { name, email, whatsapp, password } = req.body
|
||||
|
||||
if (!name || !email || !whatsapp || !password) {
|
||||
res.status(400).json({ message: "Semua field wajib diisi." })
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
res.status(400).json({ message: "Password minimal 6 karakter." })
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
const [result] = await pool.query(
|
||||
"INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)",
|
||||
[name, email.toLowerCase(), whatsapp, passwordHash]
|
||||
)
|
||||
|
||||
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" }
|
||||
res.status(201).json({ token: signUser(user), user: publicUser(user) })
|
||||
} catch (error) {
|
||||
if (error.code === "ER_DUP_ENTRY") {
|
||||
res.status(409).json({ message: "Email sudah terdaftar." })
|
||||
return
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/api/auth/login", async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ message: "Email dan password wajib diisi." })
|
||||
return
|
||||
}
|
||||
|
||||
const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()])
|
||||
const user = rows[0]
|
||||
|
||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||
res.status(401).json({ message: "Email atau password salah." })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ token: signUser(user), user: publicUser(user) })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/api/auth/me", auth, async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id])
|
||||
if (!rows[0]) {
|
||||
res.status(404).json({ message: "User tidak ditemukan." })
|
||||
return
|
||||
}
|
||||
res.json({ user: rows[0] })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
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"
|
||||
)
|
||||
res.json({ products: await attachProductImages(rows) })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/api/products/mine", auth, 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 WHERE user_id = ? ORDER BY created_at DESC, id DESC",
|
||||
[req.user.id]
|
||||
)
|
||||
res.json({ products: await attachProductImages(rows) })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/api/products/:id", async (req, res, next) => {
|
||||
try {
|
||||
const product = await findProductById(req.params.id)
|
||||
if (!product) {
|
||||
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||
return
|
||||
}
|
||||
res.json({ product })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/api/products", auth, upload.array("images", 6), async (req, res, next) => {
|
||||
try {
|
||||
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
||||
|
||||
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.files?.length) {
|
||||
res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." })
|
||||
return
|
||||
}
|
||||
|
||||
const mainImagePath = `/uploads/${req.files[0].filename}`
|
||||
const [result] = await pool.query(
|
||||
"INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description]
|
||||
)
|
||||
|
||||
const imageRows = req.files.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index])
|
||||
await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows])
|
||||
|
||||
const product = await findProductById(result.insertId)
|
||||
res.status(201).json({ product })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/api/products/:id/status", auth, async (req, res, next) => {
|
||||
try {
|
||||
const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia"
|
||||
const [result] = await pool.query(
|
||||
"UPDATE products SET status = ? WHERE id = ? AND user_id = ?",
|
||||
[status, req.params.id, req.user.id]
|
||||
)
|
||||
|
||||
if (!result.affectedRows) {
|
||||
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
|
||||
return
|
||||
}
|
||||
|
||||
const product = await findProductById(req.params.id)
|
||||
res.json({ product })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/api/products/:id", auth, 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
|
||||
}
|
||||
|
||||
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
|
||||
await deleteUploadedImages(product)
|
||||
|
||||
res.json({ message: "Produk berhasil dihapus." })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => {
|
||||
try {
|
||||
const product = await findProductById(req.params.id)
|
||||
|
||||
if (!product) {
|
||||
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||
return
|
||||
}
|
||||
|
||||
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
|
||||
await deleteUploadedImages(product)
|
||||
|
||||
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.use((error, req, res, next) => {
|
||||
if (error instanceof multer.MulterError) {
|
||||
res.status(400).json({ message: "Upload gagal. Ukuran setiap foto maksimal 3MB dan maksimal 6 foto." })
|
||||
return
|
||||
}
|
||||
|
||||
if (error.message === "File harus berupa gambar.") {
|
||||
res.status(400).json({ message: error.message })
|
||||
return
|
||||
}
|
||||
|
||||
console.error(error)
|
||||
res.status(500).json({ message: "Terjadi kesalahan pada server." })
|
||||
})
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`SecondTech API berjalan di http://localhost:${port}`)
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 font-sans;
|
||||
}
|
||||
|
||||
.container-page {
|
||||
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-2xl border border-slate-200 bg-white shadow-sm;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply text-sm font-semibold text-slate-700;
|
||||
}
|
||||
|
||||
.btn-WA {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600;
|
||||
}
|
||||
|
||||
/* SecondTech dark mode */
|
||||
html.dark body {
|
||||
background: #121212;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html.dark header {
|
||||
background: rgba(18, 18, 18, 0.92);
|
||||
border-color: rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
background: #181818;
|
||||
border-color: rgba(37, 99, 235, 0.28);
|
||||
box-shadow: 0 12px 32px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
html.dark .input {
|
||||
background: #181818;
|
||||
border-color: rgba(37, 99, 235, 0.28);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html.dark .input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
html.dark .btn-secondary {
|
||||
background: #181818;
|
||||
border-color: rgba(37, 99, 235, 0.32);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html.dark .bg-white,
|
||||
html.dark .bg-slate-50 {
|
||||
background-color: #181818;
|
||||
}
|
||||
|
||||
html.dark .text-slate-950,
|
||||
html.dark .text-slate-900,
|
||||
html.dark .text-slate-800,
|
||||
html.dark .text-slate-700 {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html.dark .text-slate-600,
|
||||
html.dark .text-slate-500 {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
html.dark .border-slate-200 {
|
||||
border-color: rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<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>
|
||||
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden items-center gap-6 md:flex">
|
||||
<RouterLink class="nav-link" to="/">Home</RouterLink>
|
||||
<RouterLink class="nav-link" to="/marketplace">Marketplace</RouterLink>
|
||||
<RouterLink class="nav-link" to="/jual">Jual Barang</RouterLink>
|
||||
<RouterLink class="nav-link" to="/dashboard">Dashboard</RouterLink>
|
||||
<RouterLink class="nav-link" to="/tersimpan">Tersimpan</RouterLink>
|
||||
<RouterLink v-if="isSuperadmin" class="nav-link" to="/admin/products">Admin Marketplace</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="hidden items-center gap-3 md:flex">
|
||||
<button type="button" class="btn-secondary !px-3 !py-2" @click="toggleTheme">
|
||||
<Sun v-if="darkMode" size="18" />
|
||||
<Moon v-else size="18" />
|
||||
</button>
|
||||
|
||||
<template v-if="currentUser">
|
||||
<span class="text-sm font-bold text-slate-700">{{ currentUser.name }}</span>
|
||||
<span v-if="isSuperadmin" class="rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700">
|
||||
Superadmin
|
||||
</span>
|
||||
<button class="btn-secondary !px-4 !py-2" @click="logout">Logout</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">Login</RouterLink>
|
||||
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button class="rounded-xl border border-slate-200 p-2 md:hidden" @click="open = !open">
|
||||
<Menu />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="open" class="border-t border-slate-200 bg-white md:hidden">
|
||||
<div class="container-page grid gap-2 py-4">
|
||||
<RouterLink class="mobile-link" to="/" @click="open = false">Home</RouterLink>
|
||||
<RouterLink class="mobile-link" to="/marketplace" @click="open = false">Marketplace</RouterLink>
|
||||
<RouterLink class="mobile-link" to="/jual" @click="open = false">Jual Barang</RouterLink>
|
||||
<RouterLink class="mobile-link" to="/dashboard" @click="open = false">Dashboard</RouterLink>
|
||||
<RouterLink class="mobile-link" to="/tersimpan" @click="open = false">Tersimpan</RouterLink>
|
||||
<RouterLink v-if="isSuperadmin" class="mobile-link" to="/admin/products" @click="open = false">
|
||||
Admin Marketplace
|
||||
</RouterLink>
|
||||
|
||||
<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" }}
|
||||
</button>
|
||||
|
||||
<div v-if="currentUser" class="mt-2 grid gap-2">
|
||||
<div class="rounded-xl bg-slate-50 px-3 py-2">
|
||||
<p class="text-sm font-bold text-slate-800">{{ currentUser.name }}</p>
|
||||
<p class="text-xs text-slate-500">{{ currentUser.email }}</p>
|
||||
<p v-if="isSuperadmin" class="mt-1 text-xs font-bold text-red-700">Superadmin</p>
|
||||
</div>
|
||||
|
||||
<button class="btn-secondary !py-2 text-center" @click="logout">Logout</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-2 grid grid-cols-2 gap-2">
|
||||
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open = false">Login</RouterLink>
|
||||
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">Register</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</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"
|
||||
|
||||
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")
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
currentUser.value = getCurrentUser()
|
||||
}
|
||||
)
|
||||
|
||||
function applyTheme() {
|
||||
document.documentElement.classList.toggle("dark", darkMode.value)
|
||||
localStorage.setItem("secondtech_theme", darkMode.value ? "dark" : "light")
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
darkMode.value = !darkMode.value
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
async function refreshCurrentUser() {
|
||||
if (!getToken()) return
|
||||
|
||||
try {
|
||||
const data = await fetchCurrentUser()
|
||||
setAuth({ token: getToken(), user: data.user })
|
||||
currentUser.value = getCurrentUser()
|
||||
} catch {
|
||||
clearAuth()
|
||||
currentUser.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearAuth()
|
||||
currentUser.value = null
|
||||
open.value = false
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyTheme()
|
||||
refreshCurrentUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
@apply text-sm font-semibold text-slate-600 transition hover:text-brand-700;
|
||||
}
|
||||
|
||||
.router-link-active {
|
||||
@apply text-brand-700;
|
||||
}
|
||||
|
||||
.mobile-link {
|
||||
@apply rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="card group overflow-hidden">
|
||||
<div class="relative aspect-[4/3] overflow-hidden bg-slate-100">
|
||||
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="h-full w-full object-cover transition duration-500 group-hover:scale-105" />
|
||||
<span class="absolute left-3 top-3 rounded-full bg-white/90 px-3 py-1 text-xs font-bold text-slate-700">
|
||||
{{ product.category }}
|
||||
</span>
|
||||
<button
|
||||
class="absolute right-3 top-3 rounded-full bg-white/90 p-2 text-slate-700 transition hover:bg-brand-600 hover:text-white"
|
||||
@click.prevent="$emit('toggle-save', product.id)"
|
||||
>
|
||||
<Heart :fill="saved ? 'currentColor' : 'none'" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
|
||||
{{ product.condition }}
|
||||
</span>
|
||||
<span class="text-xs font-semibold text-slate-500">{{ product.location }}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="line-clamp-2 min-h-[48px] text-base font-bold text-slate-900">
|
||||
{{ product.title }}
|
||||
</h3>
|
||||
|
||||
<p class="mt-2 text-lg font-extrabold text-brand-700">
|
||||
{{ formatRupiah(product.price) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<RouterLink :to="`/produk/${product.id}`" class="btn-secondary flex-1 !px-3 !py-2 text-center">
|
||||
Detail
|
||||
</RouterLink>
|
||||
<a :href="waLink" target="_blank" class="btn-primary flex-1 !px-3 !py-2 text-center">
|
||||
Massage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue"
|
||||
import { Heart } from "lucide-vue-next"
|
||||
import { formatRupiah, getProductImageUrl } from "../utils"
|
||||
|
||||
const props = defineProps({
|
||||
product: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
saved: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(["toggle-save"])
|
||||
|
||||
const waLink = computed(() => {
|
||||
const text = encodeURIComponent(`Halo, saya tertarik dengan ${props.product.title} di SecondTech Market.`)
|
||||
return `https://wa.me/${props.product.whatsapp}?text=${text}`
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,137 @@
|
||||
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 categories = [
|
||||
{
|
||||
name: "Semua",
|
||||
icon: "Boxes"
|
||||
},
|
||||
{
|
||||
name: "Laptop",
|
||||
icon: "Laptop"
|
||||
},
|
||||
{
|
||||
name: "PC",
|
||||
icon: "PcCase"
|
||||
},
|
||||
{
|
||||
name: "Monitor",
|
||||
icon: "Monitor"
|
||||
},
|
||||
{
|
||||
name: "Keyboard",
|
||||
icon: "Keyboard"
|
||||
},
|
||||
{
|
||||
name: "Router",
|
||||
icon: "Router"
|
||||
},
|
||||
{
|
||||
name: "Switch",
|
||||
icon: "Network"
|
||||
},
|
||||
{
|
||||
name: "Server",
|
||||
icon: "Server"
|
||||
},
|
||||
{
|
||||
name: "Access Point",
|
||||
icon: "Wifi"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
<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-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">
|
||||
<button
|
||||
v-for="image in productImages"
|
||||
:key="image"
|
||||
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" 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>
|
||||
</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 []
|
||||
return product.value.images?.length ? product.value.images : [product.value.image]
|
||||
})
|
||||
|
||||
watch(
|
||||
productImages,
|
||||
(images) => {
|
||||
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>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<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>
|
||||
</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">
|
||||
{{ 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">
|
||||
<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>
|
||||
<input type="file" accept="image/*" multiple class="input mt-2" required @change="handleImageChange" />
|
||||
|
||||
<div v-if="previewUrls.length" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<img
|
||||
v-for="preview in previewUrls"
|
||||
:key="preview"
|
||||
:src="preview"
|
||||
alt="Preview foto barang"
|
||||
class="aspect-[4/3] w-full rounded-xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-xs text-slate-500">Upload maksimal 6 foto. Foto pertama menjadi foto utama.</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 ? "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>- Jelaskan minus barang kalau ada.</li>
|
||||
<li>- Pakai nomor WhatsApp aktif.</li>
|
||||
<li>- Upload beberapa foto dari sisi yang berbeda.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 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("")
|
||||
|
||||
const currentUser = getCurrentUser()
|
||||
if (currentUser) {
|
||||
form.seller = currentUser.name || ""
|
||||
form.whatsapp = currentUser.whatsapp || ""
|
||||
}
|
||||
|
||||
function handleImageChange(event) {
|
||||
const files = Array.from(event.target.files || []).slice(0, 6)
|
||||
selectedImages.value = files
|
||||
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
||||
previewUrls.value = files.map((file) => URL.createObjectURL(file))
|
||||
}
|
||||
|
||||
async function submitProduct() {
|
||||
if (!selectedImages.value.length) {
|
||||
errorMessage.value = "Minimal 1 foto barang wajib diupload."
|
||||
return
|
||||
}
|
||||
|
||||
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 createProduct(payload)
|
||||
router.push("/dashboard")
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js}"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Inter", "system-ui", "sans-serif"]
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
500: "#2563eb",
|
||||
600: "#1d4ed8",
|
||||
700: "#1e40af",
|
||||
900: "#172554"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user