fitur fitur darkmode dan foto foto

This commit is contained in:
2026-05-19 10:47:55 +07:00
parent 7e1cb94689
commit 6c36f7565e
57 changed files with 7867 additions and 236 deletions
@@ -0,0 +1,58 @@
CREATE DATABASE IF NOT EXISTS secondtech_market
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE secondtech_market;
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
email VARCHAR(160) NOT NULL UNIQUE,
whatsapp VARCHAR(30) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('user', 'superadmin') NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS products (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NULL,
title VARCHAR(180) NOT NULL,
category VARCHAR(80) NOT NULL,
price INT UNSIGNED NOT NULL,
`condition` VARCHAR(80) NOT NULL,
location VARCHAR(120) NOT NULL,
seller VARCHAR(120) NOT NULL,
whatsapp VARCHAR(30) NOT NULL,
image VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
status ENUM('Tersedia', 'Terjual') NOT NULL DEFAULT 'Tersedia',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_products_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE SET NULL
);
INSERT INTO products
(id, title, category, price, `condition`, location, seller, whatsapp, image, description, status)
VALUES
(1, 'Laptop Lenovo ThinkPad T480', 'Laptop', 3200000, 'Bekas Normal', 'Yogyakarta', 'Raka SIJA', '6281234567890', 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?q=80&w=1200&auto=format&fit=crop', 'Laptop bekas cocok untuk belajar coding, jaringan, virtual machine ringan, dan kebutuhan sekolah. RAM 8GB, SSD 256GB, keyboard normal.', 'Tersedia'),
(2, 'PC Rakitan i5 Gen 8', 'PC', 4100000, 'Bekas Normal', 'Bantul', 'Dimas Tech', '628111222333', 'https://images.unsplash.com/photo-1587202372775-e229f172b9d7?q=80&w=1200&auto=format&fit=crop', 'PC rakitan untuk lab jaringan, desain ringan, dan multitasking. Intel Core i5, RAM 16GB, SSD 512GB.', 'Tersedia'),
(3, 'Monitor LG 24 Inch IPS', 'Monitor', 1150000, 'Bekas Mulus', 'Sleman', 'Adit Hardware', '628555666777', 'https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?q=80&w=1200&auto=format&fit=crop', 'Monitor IPS 24 inch, warna masih bagus, cocok untuk coding dan editing. Include kabel power dan HDMI.', 'Tersedia'),
(4, 'Keyboard Mechanical Keychron K2', 'Keyboard', 850000, 'Bekas Normal', 'Solo', 'Naufal Keys', '628333444555', 'https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?q=80&w=1200&auto=format&fit=crop', 'Keyboard mechanical wireless, switch brown, cocok untuk coding. Kondisi normal dan keycap lengkap.', 'Tersedia'),
(5, 'MikroTik RB941 hAP Lite', 'Router', 180000, 'Bekas Normal', 'Kulon Progo', 'Lab Network', '628777888999', 'https://images.unsplash.com/photo-1606904825846-647eb07f5be2?q=80&w=1200&auto=format&fit=crop', 'Router MikroTik untuk belajar routing, firewall, DHCP, hotspot, dan konfigurasi dasar jaringan.', 'Tersedia'),
(6, 'Switch TP-Link 8 Port Gigabit', 'Switch', 250000, 'Bekas Normal', 'Magelang', 'Fajar Net', '628999111222', 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop', 'Switch 8 port gigabit, cocok untuk lab kecil, warnet mini, dan praktik jaringan lokal.', 'Tersedia'),
(7, 'Server Dell PowerEdge R620', 'Server', 6500000, 'Bekas Server Room', 'Jakarta', 'Server Bekas ID', '628222333444', 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop', 'Server rackmount bekas data center, cocok untuk belajar virtualization, Proxmox, Docker, dan homelab.', 'Tersedia'),
(8, 'Access Point TP-Link EAP225', 'Access Point', 620000, 'Bekas Mulus', 'Semarang', 'WiFi Store', '6281212121212', 'https://images.unsplash.com/photo-1544197150-b99a580bb7a8?q=80&w=1200&auto=format&fit=crop', 'Access point ceiling untuk hotspot sekolah, kantor, dan lab jaringan. Kondisi normal.', 'Tersedia')
ON DUPLICATE KEY UPDATE
title = VALUES(title),
category = VALUES(category),
price = VALUES(price),
`condition` = VALUES(`condition`),
location = VALUES(location),
seller = VALUES(seller),
whatsapp = VALUES(whatsapp),
image = VALUES(image),
description = VALUES(description),
status = VALUES(status);
@@ -0,0 +1,356 @@
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 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
const [images] = await pool.query(
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
[id]
)
product.images = images.length ? images.map((item) => item.image) : [product.image]
return product
}
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) => {
const products = await Promise.all(
rows.map(async (product) => {
const [images] = await pool.query(
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
[product.id]
)
return {
...product,
images: images.length ? images.map((item) => item.image) : [product.image]
}
})
)
res.json({ products })
})
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: 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])
if (product.image.startsWith("/uploads/")) {
fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {})
}
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])
if (product.image.startsWith("/uploads/")) {
fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {})
}
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 foto maksimal 10MB." })
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}`)
})
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)
}
}
@@ -0,0 +1,41 @@
@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;
}
@@ -0,0 +1,168 @@
<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 items-center justify-center">
<img
src="/round-logo-sija.png"
alt="SecondTech Logo"
class="h-10 w-10 object-contain"
/>
</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">
<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>
<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 } 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 isSuperadmin = computed(() => {
return currentUser.value?.role === "superadmin"
})
watch(
() => route.fullPath,
() => {
currentUser.value = getCurrentUser()
}
)
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(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,138 @@
export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000"
export function formatRupiah(value) {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
maximumFractionDigits: 0
}).format(value)
}
export function getLocal(key, fallback) {
try {
const value = localStorage.getItem(key)
return value ? JSON.parse(value) : fallback
} catch {
return fallback
}
}
export function setLocal(key, value) {
localStorage.setItem(key, JSON.stringify(value))
}
export function getToken() {
return localStorage.getItem("secondtech_token")
}
export function getCurrentUser() {
return getLocal("secondtech_user", null)
}
export function setAuth({ token, user }) {
localStorage.setItem("secondtech_token", token)
setLocal("secondtech_user", {
id: user.id,
name: user.name,
email: user.email,
whatsapp: user.whatsapp,
role: user.role || "user"
})
}
export function clearAuth() {
localStorage.removeItem("secondtech_token")
localStorage.removeItem("secondtech_user")
}
export function getProductImageUrl(image) {
if (!image) return ""
if (image.startsWith("http://") || image.startsWith("https://") || image.startsWith("data:")) return image
return `${API_BASE_URL}${image}`
}
async function requestJson(path, options = {}) {
const headers = new Headers(options.headers || {})
const token = getToken()
if (token) headers.set("Authorization", `Bearer ${token}`)
if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json")
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || "Request gagal.")
}
return data
}
export function registerUser(payload) {
return requestJson("/api/auth/register", {
method: "POST",
body: JSON.stringify(payload)
})
}
export function loginUser(payload) {
return requestJson("/api/auth/login", {
method: "POST",
body: JSON.stringify(payload)
})
}
export function fetchCurrentUser() {
return requestJson("/api/auth/me")
}
export function fetchProducts() {
return requestJson("/api/products")
}
export function fetchProduct(id) {
return requestJson(`/api/products/${id}`)
}
export function fetchMyProducts() {
return requestJson("/api/products/mine")
}
export function createProduct(formData) {
return requestJson("/api/products", {
method: "POST",
body: formData
})
}
export function updateProductStatus(id, status) {
return requestJson(`/api/products/${id}/status`, {
method: "PATCH",
body: JSON.stringify({ status })
})
}
export function deleteProductById(id) {
return requestJson(`/api/products/${id}`, {
method: "DELETE"
})
}
export function deleteProductAsAdmin(id) {
return requestJson(`/api/admin/products/${id}`, {
method: "DELETE"
})
}
export function getSavedProducts() {
return getLocal("secondtech_saved", [])
}
export function setSavedProducts(ids) {
setLocal("secondtech_saved", ids)
}
@@ -0,0 +1,105 @@
<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="card overflow-hidden">
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
</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-WA">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 } 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 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 {
product.value = defaultProducts.find((item) => Number(item.id) === id) || null
} finally {
loading.value = false
}
}
onMounted(loadProduct)
</script>
@@ -0,0 +1,171 @@
<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 anda ke marketplace dengan foto produk asli.</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>
<img v-if="previewUrl" :src="previewUrl" alt="Preview foto barang" class="mt-4 aspect-[4/3] w-full max-w-sm rounded-xl object-cover" />
<p class="mt-2 text-xs text-slate-500">Upload JPG, PNG, atau WebP. Maksimal 3MB.</p>
</div>
<div>
<label class="label">Deskripsi</label>
<textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Deskripsikan kondisi barang anda..." 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> Pakai foto yang terang dan jelas.</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 selectedImage = ref(null)
const previewUrl = 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 file = event.target.files?.[0]
selectedImage.value = file || null
previewUrl.value = file ? URL.createObjectURL(file) : ""
}
async function submitProduct() {
if (!selectedImage.value) {
errorMessage.value = "Foto barang wajib diupload."
return
}
loading.value = true
errorMessage.value = ""
const payload = new FormData()
Object.entries(form).forEach(([key, value]) => {
payload.append(key, value)
})
payload.append("image", selectedImage.value)
try {
await createProduct(payload)
router.push("/dashboard")
} catch (error) {
errorMessage.value = error.message
} finally {
loading.value = false
}
}
</script>
@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
export default {
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: []
}