1 Commits

Author SHA1 Message Date
miko da92e21c2a mengubah foto 2026-05-19 10:46:00 +07:00
62 changed files with 99 additions and 8112 deletions
-10
View File
@@ -1,10 +0,0 @@
CREATE TABLE IF NOT EXISTS product_images (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
image VARCHAR(255) NOT NULL,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_product_images_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE
);
@@ -1,58 +0,0 @@
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);
@@ -1,356 +0,0 @@
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)
}
}
@@ -1,41 +0,0 @@
@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;
}
@@ -1,168 +0,0 @@
<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>
@@ -1,138 +0,0 @@
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)
}
@@ -1,105 +0,0 @@
<template>
<section class="container-page py-10">
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
Kembali ke Marketplace
</RouterLink>
<div v-if="loading" class="card p-10 text-center">
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
</div>
<div v-else-if="product" class="grid gap-8 lg:grid-cols-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>
@@ -1,171 +0,0 @@
<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>
@@ -1,25 +0,0 @@
/** @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: []
}
@@ -1,69 +0,0 @@
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);
CREATE TABLE IF NOT EXISTS product_images (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
image VARCHAR(255) NOT NULL,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_product_images_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE
);
@@ -1,348 +0,0 @@
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}`)
})
@@ -1,95 +0,0 @@
@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);
}
@@ -1,150 +0,0 @@
<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>
@@ -1,137 +0,0 @@
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)
}
@@ -1,135 +0,0 @@
<template>
<section class="container-page py-10">
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
Kembali ke Marketplace
</RouterLink>
<div v-if="loading" class="card p-10 text-center">
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
</div>
<div v-else-if="product" class="grid gap-8 lg:grid-cols-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>
@@ -1,149 +0,0 @@
<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/*" class="input mt-2" required @change="handleImageChange" />
<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>
@@ -1,27 +0,0 @@
/** @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: []
}
@@ -1,304 +0,0 @@
@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.94);
border-color: rgba(37, 99, 235, 0.34);
}
html.dark main,
html.dark section {
background: #121212;
}
html.dark .card {
background: #181818;
border-color: rgba(37, 99, 235, 0.30);
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.20);
}
html.dark .input {
background: #181818;
border-color: rgba(37, 99, 235, 0.34);
color: #f8fafc;
}
html.dark .input::placeholder {
color: #94a3b8;
}
html.dark .btn-secondary {
background: #181818;
border-color: rgba(37, 99, 235, 0.38);
color: #f8fafc;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12);
}
html.dark .btn-primary {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.24);
}
html.dark .bg-white,
html.dark .bg-slate-50,
html.dark .bg-slate-100 {
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.30);
}
/* SecondTech stronger dark mode repair */
html.dark,
html.dark body {
background: #121212 !important;
color: #f8fafc !important;
}
html.dark header,
html.dark footer,
html.dark main,
html.dark section {
background-color: #121212 !important;
}
html.dark header {
border-color: rgba(37, 99, 235, 0.35) !important;
}
html.dark .card {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.34) !important;
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important;
}
html.dark .input,
html.dark input,
html.dark select,
html.dark textarea {
background-color: #181818 !important;
border-color: rgba(37, 99, 235, 0.38) !important;
color: #f8fafc !important;
}
html.dark .input::placeholder,
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #94a3b8 !important;
}
html.dark .btn-secondary {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.42) !important;
color: #f8fafc !important;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important;
}
html.dark .btn-primary {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important;
}
html.dark .bg-white,
html.dark .bg-slate-50,
html.dark .bg-slate-100,
html.dark .bg-brand-50 {
background-color: #181818 !important;
}
html.dark .text-slate-950,
html.dark .text-slate-900,
html.dark .text-slate-800,
html.dark .text-slate-700,
html.dark .text-brand-900,
html.dark .text-brand-800,
html.dark .text-brand-700 {
color: #f8fafc !important;
}
html.dark .text-slate-600,
html.dark .text-slate-500,
html.dark .text-slate-400 {
color: #cbd5e1 !important;
}
html.dark .nav-link,
html.dark .mobile-link,
html.dark a:not(.btn-primary) {
color: #dbeafe !important;
}
html.dark .nav-link:hover,
html.dark .mobile-link:hover,
html.dark a:not(.btn-primary):hover {
color: #ffffff !important;
}
html.dark .border-slate-200,
html.dark .border-brand-600 {
border-color: rgba(37, 99, 235, 0.42) !important;
}
html.dark .shadow-sm {
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important;
}
/* SecondTech final dark mode repair */
html.dark,
html.dark body {
background: #121212 !important;
color: #f8fafc !important;
}
html.dark header,
html.dark footer,
html.dark main,
html.dark section {
background-color: #121212 !important;
}
html.dark header {
border-color: rgba(37, 99, 235, 0.35) !important;
}
html.dark .card {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.34) !important;
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important;
}
html.dark .input,
html.dark input,
html.dark select,
html.dark textarea {
background-color: #181818 !important;
border-color: rgba(37, 99, 235, 0.38) !important;
color: #f8fafc !important;
}
html.dark .input::placeholder,
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #94a3b8 !important;
}
html.dark .btn-secondary {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.42) !important;
color: #f8fafc !important;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important;
}
html.dark .btn-primary {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important;
}
html.dark .bg-white,
html.dark .bg-slate-50,
html.dark .bg-slate-100,
html.dark .bg-brand-50,
html.dark .bg-emerald-50,
html.dark .bg-red-50 {
background-color: #181818 !important;
}
html.dark .text-zinc-950,
html.dark .text-zinc-900,
html.dark .text-zinc-800,
html.dark .text-slate-950,
html.dark .text-slate-900,
html.dark .text-slate-800,
html.dark .text-slate-700,
html.dark .text-brand-900,
html.dark .text-brand-800,
html.dark .text-brand-700,
html.dark .text-emerald-700,
html.dark .text-red-700 {
color: #f8fafc !important;
}
html.dark .text-slate-600,
html.dark .text-slate-500,
html.dark .text-slate-400,
html.dark .text-zinc-600,
html.dark .text-zinc-500,
html.dark .text-zinc-400 {
color: #cbd5e1 !important;
}
html.dark .nav-link,
html.dark .mobile-link,
html.dark a:not(.btn-primary) {
color: #dbeafe !important;
}
html.dark .nav-link:hover,
html.dark .mobile-link:hover,
html.dark a:not(.btn-primary):hover {
color: #ffffff !important;
}
html.dark .border-slate-200,
html.dark .border-brand-600 {
border-color: rgba(37, 99, 235, 0.42) !important;
}
html.dark .shadow-sm {
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important;
}
@@ -1,127 +0,0 @@
<template>
<aside class="card p-4">
<div class="mb-6">
<p class="text-xs font-bold uppercase tracking-wider text-slate-500">
Menu User
</p>
<h2 class="mt-1 text-lg font-extrabold">Dashboard</h2>
<div v-if="currentUser" class="mt-4 rounded-xl bg-slate-50 p-3">
<p class="text-sm font-bold text-slate-800">
{{ currentUser.name }}
</p>
<p class="mt-1 text-xs text-slate-500">
{{ currentUser.email }}
</p>
<p
v-if="isSuperadmin"
class="mt-2 inline-flex rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700"
>
Superadmin
</p>
</div>
</div>
<nav class="grid gap-2">
<RouterLink class="side-link" to="/dashboard">
<LayoutDashboard size="18" />
Barang Saya
</RouterLink>
<RouterLink class="side-link" to="/jual">
<PlusCircle size="18" />
Jual Barang
</RouterLink>
<RouterLink class="side-link" to="/marketplace">
<Store size="18" />
Marketplace
</RouterLink>
<RouterLink class="side-link" to="/tersimpan">
<Heart size="18" />
Barang Tersimpan
</RouterLink>
<RouterLink
v-if="isSuperadmin"
class="side-link admin-link"
to="/admin/products"
>
<ShieldCheck size="18" />
Admin Marketplace
</RouterLink>
<button class="side-link text-left" type="button" @click="logout">
<LogOut size="18" />
Logout
</button>
</nav>
</aside>
</template>
<script setup>
import { computed, onMounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import {
Heart,
LayoutDashboard,
LogOut,
PlusCircle,
ShieldCheck,
Store
} from "lucide-vue-next"
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
const router = useRouter()
const route = useRoute()
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()
router.push("/login")
}
onMounted(refreshCurrentUser)
</script>
<style scoped>
.side-link {
@apply flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-100;
}
.admin-link {
@apply bg-red-50 text-red-700 hover:bg-red-100;
}
.router-link-active {
@apply bg-brand-50 text-brand-700;
}
</style>
@@ -1,66 +0,0 @@
<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>
@@ -1,114 +0,0 @@
<template>
<section class="bg-gradient-to-br from-brand-50 via-white to-slate-100">
<div class="container-page grid min-h-[560px] items-center gap-10 py-16 lg:grid-cols-2">
<div>
<ShinyText
text="SecondTech"
className="text-1xl font-extrabold tracking-tight sm:text-1xl lg:text-2xl"
:color="'#1c4ed9'"
:shineColor="'#e0e7ff'"
:spread="45"
:speed="1"
:delay="2"
:direction="'left'"
:pauseOnHover="true"
:yoyo="true"
/>
<div>
<TextType
as="h2"
className="mt-6 text-3xl font-extrabold tracking-tight text-zinc-950 sm:text-6xl lg:text-5xl"
:text="['Menjual Barang Bekas, yang kamu butuhkan, di satu tempat.']"
:typingSpeed="75"
:pauseDuration="1500"
:showCursor="false"
:textColors="['#0f1729']"
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>
<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>
</div>
</div>
<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" />
</div>
</div>
</section>
<section class="container-page py-14">
<div class="mb-8 flex items-end justify-between gap-4">
<div>
<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>
</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 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>
<p class="font-bold">{{ cat.name }}</p>
</div>
</div>
</section>
<section class="bg-white py-14">
<div class="container-page">
<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>
</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>
</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 { products, categoryOptions } from '../data/products';
import ShinyText from '../vuebits/ShinyText/ShinyText.vue';
import TextType from '../vuebits/TextType/TextType.vue';
const saved = ref(getSavedProducts());
const categoryList = categoryOptions
.filter((category) => category.name !== 'Semua')
.slice(0, 6);
const latestProducts = computed(() => products.slice(0, 4));
const iconMap = {
Boxes,
Laptop,
PcCase,
Monitor,
Keyboard,
Router,
Network,
Server,
Wifi,
};
function toggleSave(id) {
if (saved.value.includes(id)) {
saved.value = saved.value.filter((item) => item !== id);
} else {
saved.value.push(id);
}
setSavedProducts(saved.value);
}
</script>
@@ -1,348 +0,0 @@
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}`)
})
@@ -1,95 +0,0 @@
@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);
}
@@ -1,150 +0,0 @@
<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>
@@ -1,66 +0,0 @@
<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>
@@ -1,137 +0,0 @@
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"
}
]
@@ -1,135 +0,0 @@
<template>
<section class="container-page py-10">
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
Kembali ke Marketplace
</RouterLink>
<div v-if="loading" class="card p-10 text-center">
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
</div>
<div v-else-if="product" class="grid gap-8 lg:grid-cols-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>
@@ -1,162 +0,0 @@
<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>
@@ -1,27 +0,0 @@
/** @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: []
}
@@ -1,395 +0,0 @@
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 maxImageSizeMb = Number(process.env.MAX_IMAGE_SIZE_MB || 10)
const maxImageCount = Number(process.env.MAX_IMAGE_COUNT || 6)
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: maxImageSizeMb * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith("image/")) {
cb(new Error("File harus berupa gambar."))
return
}
cb(null, true)
}
})
async function ensureSchema() {
await pool.query(`
CREATE TABLE IF NOT EXISTS product_images (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
image VARCHAR(255) NOT NULL,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_product_images_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE
)
`)
}
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)
}
}
function getUploadedImages(req) {
if (Array.isArray(req.files)) return req.files.slice(0, maxImageCount)
return [
...(req.files?.images || []),
...(req.files?.image || [])
].slice(0, maxImageCount)
}
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]
)
const imagePaths = images.map((item) => item.image)
if (imagePaths.length) return imagePaths
return fallbackImage ? [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.fields([
{ name: "images", maxCount: maxImageCount },
{ name: "image", maxCount: maxImageCount }
]),
async (req, res, next) => {
try {
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
const uploadedImages = getUploadedImages(req)
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !uploadedImages.length) {
res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." })
return
}
const mainImagePath = `/uploads/${uploadedImages[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 = uploadedImages.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 ${maxImageSizeMb} MB dan maksimal ${maxImageCount} 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.",
detail: process.env.NODE_ENV === "production" ? undefined : error.message
})
})
ensureSchema()
.then(() => {
app.listen(port, () => {
console.log(`SecondTech API berjalan di http://localhost:${port}`)
console.log(`Upload foto: maksimal ${maxImageSizeMb} MB per foto, maksimal ${maxImageCount} foto.`)
})
})
.catch((error) => {
console.error("Gagal menyiapkan database:", error)
process.exit(1)
})
@@ -1,200 +0,0 @@
@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.94);
border-color: rgba(37, 99, 235, 0.34);
}
html.dark main,
html.dark section {
background: #121212;
}
html.dark .card {
background: #181818;
border-color: rgba(37, 99, 235, 0.30);
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.20);
}
html.dark .input {
background: #181818;
border-color: rgba(37, 99, 235, 0.34);
color: #f8fafc;
}
html.dark .input::placeholder {
color: #94a3b8;
}
html.dark .btn-secondary {
background: #181818;
border-color: rgba(37, 99, 235, 0.38);
color: #f8fafc;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12);
}
html.dark .btn-primary {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.24);
}
html.dark .bg-white,
html.dark .bg-slate-50,
html.dark .bg-slate-100 {
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.30);
}
/* SecondTech stronger dark mode repair */
html.dark,
html.dark body {
background: #121212 !important;
color: #f8fafc !important;
}
html.dark header,
html.dark footer,
html.dark main,
html.dark section {
background-color: #121212 !important;
}
html.dark header {
border-color: rgba(37, 99, 235, 0.35) !important;
}
html.dark .card {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.34) !important;
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important;
}
html.dark .input,
html.dark input,
html.dark select,
html.dark textarea {
background-color: #181818 !important;
border-color: rgba(37, 99, 235, 0.38) !important;
color: #f8fafc !important;
}
html.dark .input::placeholder,
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #94a3b8 !important;
}
html.dark .btn-secondary {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.42) !important;
color: #f8fafc !important;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important;
}
html.dark .btn-primary {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important;
}
html.dark .bg-white,
html.dark .bg-slate-50,
html.dark .bg-slate-100,
html.dark .bg-brand-50 {
background-color: #181818 !important;
}
html.dark .text-slate-950,
html.dark .text-slate-900,
html.dark .text-slate-800,
html.dark .text-slate-700,
html.dark .text-brand-900,
html.dark .text-brand-800,
html.dark .text-brand-700 {
color: #f8fafc !important;
}
html.dark .text-slate-600,
html.dark .text-slate-500,
html.dark .text-slate-400 {
color: #cbd5e1 !important;
}
html.dark .nav-link,
html.dark .mobile-link,
html.dark a:not(.btn-primary) {
color: #dbeafe !important;
}
html.dark .nav-link:hover,
html.dark .mobile-link:hover,
html.dark a:not(.btn-primary):hover {
color: #ffffff !important;
}
html.dark .border-slate-200,
html.dark .border-brand-600 {
border-color: rgba(37, 99, 235, 0.42) !important;
}
html.dark .shadow-sm {
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important;
}
@@ -1,138 +0,0 @@
<template>
<section class="container-page py-10">
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
<- Kembali ke Marketplace
</RouterLink>
<div v-if="loading" class="card p-10 text-center">
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
</div>
<div v-else-if="product" class="grid gap-8 lg:grid-cols-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, index) in productImages"
:key="`${image}-${index}`"
type="button"
class="h-20 w-20 shrink-0 overflow-hidden rounded-xl border-2 bg-white"
:class="selectedImage === image ? 'border-brand-600' : 'border-slate-200'"
@click="selectedImage = image"
>
<img :src="getProductImageUrl(image)" :alt="`${product.title} ${index + 1}`" class="h-full w-full object-cover" />
</button>
</div>
<div class="card order-1 overflow-hidden sm:order-2">
<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 []
const images = product.value.images?.length ? product.value.images : [product.value.image]
return [...new Set(images.filter(Boolean))]
})
watch(
productImages,
(images) => {
if (!images.includes(selectedImage.value)) {
selectedImage.value = images[0] || ""
}
},
{ immediate: true }
)
const savedIds = ref(getSavedProducts())
const saved = computed(() => savedIds.value.includes(id))
const waLink = computed(() => {
if (!product.value) return "#"
const text = encodeURIComponent(`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`)
return `https://wa.me/${product.value.whatsapp}?text=${text}`
})
function toggleSave() {
if (saved.value) {
savedIds.value = savedIds.value.filter((item) => item !== id)
} else {
savedIds.value.push(id)
}
setSavedProducts(savedIds.value)
}
async function loadProduct() {
loading.value = true
try {
const data = await fetchProduct(id)
product.value = data.product
} catch {
const fallbackProduct = defaultProducts.find((item) => Number(item.id) === id) || null
product.value = fallbackProduct ? { ...fallbackProduct, images: [fallbackProduct.image] } : null
} finally {
loading.value = false
}
}
onMounted(loadProduct)
</script>
@@ -1,173 +0,0 @@
<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" required>
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
<div>
<label class="label">Harga</label>
<input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required />
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="label">Kondisi</label>
<select v-model="form.condition" class="input mt-2">
<option>Bekas Normal</option>
<option>Bekas Mulus</option>
<option>Bekas Minus</option>
<option>Bekas Server Room</option>
</select>
</div>
<div>
<label class="label">Lokasi</label>
<input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required />
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="label">Nama Penjual</label>
<input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required />
</div>
<div>
<label class="label">Nomor WhatsApp</label>
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
</div>
</div>
<div>
<label class="label">Foto Barang</label>
<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">
<div v-for="preview in previewUrls" :key="preview" class="overflow-hidden rounded-xl border border-slate-200">
<img :src="preview" alt="Preview foto barang" class="aspect-[4/3] w-full object-cover" />
</div>
</div>
<p class="mt-2 text-xs text-slate-500">Upload maksimal 6 foto. Maksimal 10 MB per 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 MAX_IMAGE_SIZE_MB = 10
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024
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)
const oversizedFile = files.find((file) => file.size > MAX_IMAGE_SIZE)
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
if (oversizedFile) {
selectedImages.value = []
previewUrls.value = []
event.target.value = ""
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`
return
}
selectedImages.value = files
previewUrls.value = files.map((file) => URL.createObjectURL(file))
errorMessage.value = ""
}
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>
@@ -1,393 +0,0 @@
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 maxImageSizeMb = Number(process.env.MAX_IMAGE_SIZE_MB || 10)
const maxImageCount = Number(process.env.MAX_IMAGE_COUNT || 6)
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: maxImageSizeMb * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith("image/")) {
cb(new Error("File harus berupa gambar."))
return
}
cb(null, true)
}
})
async function ensureSchema() {
await pool.query(`
CREATE TABLE IF NOT EXISTS product_images (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
image VARCHAR(255) NOT NULL,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_product_images_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE
)
`)
}
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)
}
}
function getUploadedImages(req) {
if (Array.isArray(req.files)) return req.files
return [
...(req.files?.images || []),
...(req.files?.image || [])
].slice(0, maxImageCount)
}
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.fields([
{ name: "images", maxCount: maxImageCount },
{ name: "image", maxCount: maxImageCount }
]),
async (req, res, next) => {
try {
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
const uploadedImages = getUploadedImages(req)
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !uploadedImages.length) {
res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." })
return
}
const mainImagePath = `/uploads/${uploadedImages[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 = uploadedImages.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 ${maxImageSizeMb} MB dan maksimal ${maxImageCount} 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.",
detail: process.env.NODE_ENV === "production" ? undefined : error.message
})
})
ensureSchema()
.then(() => {
app.listen(port, () => {
console.log(`SecondTech API berjalan di http://localhost:${port}`)
console.log(`Upload foto: maksimal ${maxImageSizeMb} MB per foto, maksimal ${maxImageCount} foto.`)
})
})
.catch((error) => {
console.error("Gagal menyiapkan database:", error)
process.exit(1)
})
@@ -1,106 +0,0 @@
@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.94);
border-color: rgba(37, 99, 235, 0.34);
}
html.dark main,
html.dark section {
background: #121212;
}
html.dark .card {
background: #181818;
border-color: rgba(37, 99, 235, 0.30);
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.20);
}
html.dark .input {
background: #181818;
border-color: rgba(37, 99, 235, 0.34);
color: #f8fafc;
}
html.dark .input::placeholder {
color: #94a3b8;
}
html.dark .btn-secondary {
background: #181818;
border-color: rgba(37, 99, 235, 0.38);
color: #f8fafc;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12);
}
html.dark .btn-primary {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.24);
}
html.dark .bg-white,
html.dark .bg-slate-50,
html.dark .bg-slate-100 {
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.30);
}
@@ -1,66 +0,0 @@
<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>
@@ -1,137 +0,0 @@
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)
}
@@ -1,135 +0,0 @@
<template>
<section class="container-page py-10">
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
Kembali ke Marketplace
</RouterLink>
<div v-if="loading" class="card p-10 text-center">
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
</div>
<div v-else-if="product" class="grid gap-8 lg:grid-cols-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>
@@ -1,173 +0,0 @@
<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" required>
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
<div>
<label class="label">Harga</label>
<input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required />
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="label">Kondisi</label>
<select v-model="form.condition" class="input mt-2">
<option>Bekas Normal</option>
<option>Bekas Mulus</option>
<option>Bekas Minus</option>
<option>Bekas Server Room</option>
</select>
</div>
<div>
<label class="label">Lokasi</label>
<input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required />
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="label">Nama Penjual</label>
<input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required />
</div>
<div>
<label class="label">Nomor WhatsApp</label>
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
</div>
</div>
<div>
<label class="label">Foto Barang</label>
<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">
<div v-for="preview in previewUrls" :key="preview" class="overflow-hidden rounded-xl border border-slate-200">
<img :src="preview" alt="Preview foto barang" class="aspect-[4/3] w-full object-cover" />
</div>
</div>
<p class="mt-2 text-xs text-slate-500">Upload maksimal 6 foto. Maksimal 10 MB per 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 MAX_IMAGE_SIZE_MB = 10
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024
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)
const oversizedFile = files.find((file) => file.size > MAX_IMAGE_SIZE)
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
if (oversizedFile) {
selectedImages.value = []
previewUrls.value = []
event.target.value = ""
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`
return
}
selectedImages.value = files
previewUrls.value = files.map((file) => URL.createObjectURL(file))
errorMessage.value = ""
}
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>
@@ -1,158 +0,0 @@
<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" required>
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
<div>
<label class="label">Harga</label>
<input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required />
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="label">Kondisi</label>
<select v-model="form.condition" class="input mt-2">
<option>Bekas Normal</option>
<option>Bekas Mulus</option>
<option>Bekas Minus</option>
<option>Bekas Server Room</option>
</select>
</div>
<div>
<label class="label">Lokasi</label>
<input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required />
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="label">Nama Penjual</label>
<input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required />
</div>
<div>
<label class="label">Nomor WhatsApp</label>
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
</div>
</div>
<div>
<label class="label">Foto Barang</label>
<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">
<div v-for="preview in previewUrls" :key="preview" class="overflow-hidden rounded-xl border border-slate-200">
<img :src="preview" alt="Preview foto barang" class="aspect-[4/3] w-full object-cover" />
</div>
</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>
@@ -1,348 +0,0 @@
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}`)
})
@@ -1,349 +0,0 @@
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: 10 * 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 10 MB 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}`)
})
@@ -1,137 +0,0 @@
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)
}
@@ -1,173 +0,0 @@
<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" required>
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
<div>
<label class="label">Harga</label>
<input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required />
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="label">Kondisi</label>
<select v-model="form.condition" class="input mt-2">
<option>Bekas Normal</option>
<option>Bekas Mulus</option>
<option>Bekas Minus</option>
<option>Bekas Server Room</option>
</select>
</div>
<div>
<label class="label">Lokasi</label>
<input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required />
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="label">Nama Penjual</label>
<input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required />
</div>
<div>
<label class="label">Nomor WhatsApp</label>
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
</div>
</div>
<div>
<label class="label">Foto Barang</label>
<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">
<div v-for="preview in previewUrls" :key="preview" class="overflow-hidden rounded-xl border border-slate-200">
<img :src="preview" alt="Preview foto barang" class="aspect-[4/3] w-full object-cover" />
</div>
</div>
<p class="mt-2 text-xs text-slate-500">Upload maksimal 6 foto. Maksimal 10 MB per 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 MAX_IMAGE_SIZE_MB = 10
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024
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)
const oversizedFile = files.find((file) => file.size > MAX_IMAGE_SIZE)
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
if (oversizedFile) {
selectedImages.value = []
previewUrls.value = []
event.target.value = ""
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`
return
}
selectedImages.value = files
previewUrls.value = files.map((file) => URL.createObjectURL(file))
errorMessage.value = ""
}
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>
-12
View File
@@ -10,7 +10,6 @@ CREATE TABLE IF NOT EXISTS users (
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
);
@@ -56,14 +55,3 @@ ON DUPLICATE KEY UPDATE
image = VALUES(image),
description = VALUES(description),
status = VALUES(status);
CREATE TABLE IF NOT EXISTS product_images (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
image VARCHAR(255) NOT NULL,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_product_images_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+34 -184
View File
@@ -14,8 +14,6 @@ dotenv.config()
const app = express()
const port = Number(process.env.PORT || 5000)
const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret"
const maxImageSizeMb = Number(process.env.MAX_IMAGE_SIZE_MB || 10)
const maxImageCount = Number(process.env.MAX_IMAGE_COUNT || 6)
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const uploadsDir = path.join(__dirname, "uploads")
@@ -41,7 +39,7 @@ const storage = multer.diskStorage({
const upload = multer({
storage,
limits: { fileSize: maxImageSizeMb * 1024 * 1024, files: maxImageCount },
limits: { fileSize: 3 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith("image/")) {
cb(new Error("File harus berupa gambar."))
@@ -51,21 +49,6 @@ const upload = multer({
}
})
async function ensureSchema() {
await pool.query(`
CREATE TABLE IF NOT EXISTS product_images (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
image VARCHAR(255) NOT NULL,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_product_images_product
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE
)
`)
}
function signUser(user) {
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
}
@@ -75,8 +58,7 @@ function publicUser(user) {
id: user.id,
name: user.name,
email: user.email,
whatsapp: user.whatsapp,
role: user.role || "user"
whatsapp: user.whatsapp
}
}
@@ -97,81 +79,12 @@ function auth(req, res, next) {
}
}
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)
}
}
function uniqueImages(files) {
const seen = new Set()
return files.filter((file) => {
const key = `${file.originalname}-${file.size}-${file.mimetype}`
if (seen.has(key)) return false
seen.add(key)
return true
}).slice(0, maxImageCount)
}
function getUploadedImages(req) {
const files = [
...(req.files?.images || []),
...(req.files?.image || []),
...(req.files?.photos || [])
]
return uniqueImages(files)
}
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]
)
const imagePaths = images.map((item) => item.image).filter(Boolean)
if (imagePaths.length) return imagePaths
return fallbackImage ? [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 }, () => {})
}
})
return rows[0]
}
app.get("/api/health", async (req, res, next) => {
@@ -183,24 +96,6 @@ app.get("/api/health", async (req, res, next) => {
}
})
app.get("/api/debug/products/:id/images", 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_id: Number(req.params.id),
main_image: product.image,
image_count: product.images.length,
images: product.images
})
} catch (error) {
next(error)
}
})
app.post("/api/auth/register", async (req, res, next) => {
try {
const { name, email, whatsapp, password } = req.body
@@ -221,7 +116,7 @@ app.post("/api/auth/register", async (req, res, next) => {
[name, email.toLowerCase(), whatsapp, passwordHash]
)
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" }
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp }
res.status(201).json({ token: signUser(user), user: publicUser(user) })
} catch (error) {
if (error.code === "ER_DUP_ENTRY") {
@@ -257,7 +152,7 @@ app.post("/api/auth/login", async (req, res, next) => {
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])
const [rows] = await pool.query("SELECT id, name, email, whatsapp FROM users WHERE id = ?", [req.user.id])
if (!rows[0]) {
res.status(404).json({ message: "User tidak ditemukan." })
return
@@ -273,7 +168,7 @@ app.get("/api/products", async (req, res, next) => {
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) })
res.json({ products: rows })
} catch (error) {
next(error)
}
@@ -285,7 +180,7 @@ app.get("/api/products/mine", auth, async (req, res, next) => {
"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) })
res.json({ products: rows })
} catch (error) {
next(error)
}
@@ -304,46 +199,27 @@ app.get("/api/products/:id", async (req, res, next) => {
}
})
app.post(
"/api/products",
auth,
upload.fields([
{ name: "images", maxCount: maxImageCount },
{ name: "image", maxCount: maxImageCount },
{ name: "photos", maxCount: maxImageCount }
]),
async (req, res, next) => {
try {
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
const uploadedImages = getUploadedImages(req)
app.post("/api/products", auth, upload.single("image"), async (req, res, next) => {
try {
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
console.log("Upload produk:", {
title,
imageCount: uploadedImages.length,
files: uploadedImages.map((file) => file.originalname)
})
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !uploadedImages.length) {
res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." })
return
}
const mainImagePath = `/uploads/${uploadedImages[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 = uploadedImages.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)
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.file) {
res.status(400).json({ message: "Semua field produk dan foto wajib diisi." })
return
}
const imagePath = `/uploads/${req.file.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, imagePath, description]
)
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 {
@@ -374,7 +250,10 @@ app.delete("/api/products/:id", auth, async (req, res, next) => {
}
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
await deleteUploadedImages(product)
if (product.image.startsWith("/uploads/")) {
fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {})
}
res.json({ message: "Produk berhasil dihapus." })
} catch (error) {
@@ -382,27 +261,9 @@ app.delete("/api/products/:id", auth, async (req, res, next) => {
}
})
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 ${maxImageSizeMb} MB dan maksimal ${maxImageCount} foto.` })
res.status(400).json({ message: "Upload gagal. Ukuran foto maksimal 3MB." })
return
}
@@ -412,20 +273,9 @@ app.use((error, req, res, next) => {
}
console.error(error)
res.status(500).json({
message: "Terjadi kesalahan pada server.",
detail: process.env.NODE_ENV === "production" ? undefined : error.message
})
res.status(500).json({ message: "Terjadi kesalahan pada server." })
})
ensureSchema()
.then(() => {
app.listen(port, () => {
console.log(`SecondTech API berjalan di http://localhost:${port}`)
console.log(`Upload foto: maksimal ${maxImageSizeMb} MB per foto, maksimal ${maxImageCount} foto.`)
})
})
.catch((error) => {
console.error("Gagal menyiapkan database:", error)
process.exit(1)
})
app.listen(port, () => {
console.log(`SecondTech API berjalan di http://localhost:${port}`)
})

Before

Width:  |  Height:  |  Size: 826 KiB

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 990 KiB

-458
View File
@@ -39,461 +39,3 @@ body {
.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.94);
border-color: rgba(37, 99, 235, 0.34);
}
html.dark main,
html.dark section {
background: #121212;
}
html.dark .card {
background: #181818;
border-color: rgba(37, 99, 235, 0.30);
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.20);
}
html.dark .input {
background: #181818;
border-color: rgba(37, 99, 235, 0.34);
color: #f8fafc;
}
html.dark .input::placeholder {
color: #94a3b8;
}
html.dark .btn-secondary {
background: #181818;
border-color: rgba(37, 99, 235, 0.38);
color: #f8fafc;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12);
}
html.dark .btn-primary {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.24);
}
html.dark .bg-white,
html.dark .bg-slate-50,
html.dark .bg-slate-100 {
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.30);
}
/* SecondTech stronger dark mode repair */
html.dark,
html.dark body {
background: #121212 !important;
color: #f8fafc !important;
}
html.dark header,
html.dark footer,
html.dark main,
html.dark section {
background-color: #121212 !important;
}
html.dark header {
border-color: rgba(37, 99, 235, 0.35) !important;
}
html.dark .card {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.34) !important;
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important;
}
html.dark .input,
html.dark input,
html.dark select,
html.dark textarea {
background-color: #181818 !important;
border-color: rgba(37, 99, 235, 0.38) !important;
color: #f8fafc !important;
}
html.dark .input::placeholder,
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #94a3b8 !important;
}
html.dark .btn-secondary {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.42) !important;
color: #f8fafc !important;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important;
}
html.dark .btn-primary {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important;
}
html.dark .bg-white,
html.dark .bg-slate-50,
html.dark .bg-slate-100,
html.dark .bg-brand-50 {
background-color: #181818 !important;
}
html.dark .text-slate-950,
html.dark .text-slate-900,
html.dark .text-slate-800,
html.dark .text-slate-700,
html.dark .text-brand-900,
html.dark .text-brand-800,
html.dark .text-brand-700 {
color: #f8fafc !important;
}
html.dark .text-slate-600,
html.dark .text-slate-500,
html.dark .text-slate-400 {
color: #cbd5e1 !important;
}
html.dark .nav-link,
html.dark .mobile-link,
html.dark a:not(.btn-primary) {
color: #dbeafe !important;
}
html.dark .nav-link:hover,
html.dark .mobile-link:hover,
html.dark a:not(.btn-primary):hover {
color: #ffffff !important;
}
html.dark .border-slate-200,
html.dark .border-brand-600 {
border-color: rgba(37, 99, 235, 0.42) !important;
}
html.dark .shadow-sm {
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important;
}
/* SecondTech final dark mode repair */
html.dark,
html.dark body {
background: #121212 !important;
color: #f8fafc !important;
}
html.dark header,
html.dark footer,
html.dark main,
html.dark section {
background-color: #121212 !important;
}
html.dark header {
border-color: rgba(37, 99, 235, 0.35) !important;
}
html.dark .card {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.34) !important;
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important;
}
html.dark .input,
html.dark input,
html.dark select,
html.dark textarea {
background-color: #181818 !important;
border-color: rgba(37, 99, 235, 0.38) !important;
color: #f8fafc !important;
}
html.dark .input::placeholder,
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #94a3b8 !important;
}
html.dark .btn-secondary {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.42) !important;
color: #f8fafc !important;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important;
}
html.dark .btn-primary {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important;
}
html.dark .bg-white,
html.dark .bg-slate-50,
html.dark .bg-slate-100,
html.dark .bg-brand-50,
html.dark .bg-emerald-50,
html.dark .bg-red-50 {
background-color: #181818 !important;
}
html.dark .text-zinc-950,
html.dark .text-zinc-900,
html.dark .text-zinc-800,
html.dark .text-slate-950,
html.dark .text-slate-900,
html.dark .text-slate-800,
html.dark .text-slate-700,
html.dark .text-brand-900,
html.dark .text-brand-800,
html.dark .text-brand-700,
html.dark .text-emerald-700,
html.dark .text-red-700 {
color: #f8fafc !important;
}
html.dark .text-slate-600,
html.dark .text-slate-500,
html.dark .text-slate-400,
html.dark .text-zinc-600,
html.dark .text-zinc-500,
html.dark .text-zinc-400 {
color: #cbd5e1 !important;
}
html.dark .nav-link,
html.dark .mobile-link,
html.dark a:not(.btn-primary) {
color: #dbeafe !important;
}
html.dark .nav-link:hover,
html.dark .mobile-link:hover,
html.dark a:not(.btn-primary):hover {
color: #ffffff !important;
}
html.dark .border-slate-200,
html.dark .border-brand-600 {
border-color: rgba(37, 99, 235, 0.42) !important;
}
html.dark .shadow-sm {
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important;
}
/* SecondTech final dark mode override */
:root {
--hero-title-color: #0f1729;
}
html.dark {
--hero-title-color: #f8fafc;
color-scheme: dark;
}
html.dark,
html.dark body {
background: #121212 !important;
color: #f8fafc !important;
}
html.dark header,
html.dark footer,
html.dark main,
html.dark section {
background-color: #121212 !important;
}
html.dark header {
border-color: rgba(37, 99, 235, 0.36) !important;
}
html.dark .card {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.34) !important;
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important;
}
html.dark .input,
html.dark input,
html.dark select,
html.dark textarea {
background-color: #181818 !important;
border-color: rgba(37, 99, 235, 0.38) !important;
color: #f8fafc !important;
}
html.dark .input::placeholder,
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #94a3b8 !important;
}
html.dark .btn-secondary {
background: #181818 !important;
border-color: rgba(37, 99, 235, 0.42) !important;
color: #f8fafc !important;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important;
}
html.dark .btn-primary {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important;
}
html.dark .bg-white,
html.dark .bg-slate-50,
html.dark .bg-slate-100,
html.dark .bg-brand-50,
html.dark .bg-emerald-50,
html.dark .bg-red-50 {
background-color: #181818 !important;
}
html.dark .bg-white\/90 {
background-color: rgba(24, 24, 24, 0.9) !important;
color: #f8fafc !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
}
html.dark .text-zinc-950,
html.dark .text-zinc-900,
html.dark .text-zinc-800,
html.dark .text-slate-950,
html.dark .text-slate-900,
html.dark .text-slate-800,
html.dark .text-slate-700,
html.dark .text-brand-950,
html.dark .text-brand-900,
html.dark .text-brand-800,
html.dark .text-brand-700,
html.dark .text-emerald-700,
html.dark .text-red-700 {
color: #f8fafc !important;
}
html.dark .text-slate-600,
html.dark .text-slate-500,
html.dark .text-slate-400,
html.dark .text-zinc-600,
html.dark .text-zinc-500,
html.dark .text-zinc-400 {
color: #cbd5e1 !important;
}
html.dark .label,
html.dark label,
html.dark h1,
html.dark h2,
html.dark h3,
html.dark h4,
html.dark p,
html.dark span {
color: inherit;
}
html.dark .nav-link,
html.dark .mobile-link,
html.dark a:not(.btn-primary) {
color: #dbeafe !important;
}
html.dark .nav-link:hover,
html.dark .mobile-link:hover,
html.dark a:not(.btn-primary):hover {
color: #ffffff !important;
}
html.dark .border-slate-200,
html.dark .border-brand-600 {
border-color: rgba(37, 99, 235, 0.42) !important;
}
html.dark .shadow-sm {
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important;
}
/* Sidebar active links are generated by scoped component CSS, so override directly. */
html.dark .side-link {
color: #e2e8f0 !important;
}
html.dark .side-link.router-link-active {
background: rgba(37, 99, 235, 0.18) !important;
border: 1px solid rgba(37, 99, 235, 0.40) !important;
color: #ffffff !important;
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.16) !important;
}
html.dark .side-link.router-link-active svg,
html.dark .side-link svg {
color: currentColor !important;
}
html.dark .side-link.admin-link,
html.dark .side-link.admin-link.router-link-active {
background: rgba(37, 99, 235, 0.18) !important;
border: 1px solid rgba(37, 99, 235, 0.40) !important;
color: #ffffff !important;
}
/* Product cards: category badge and save button use bg-white/90. */
html.dark button.bg-white\/90,
html.dark span.bg-white\/90 {
background-color: rgba(24, 24, 24, 0.90) !important;
color: #f8fafc !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
}
html.dark button.bg-white\/90:hover {
background-color: #2563eb !important;
color: #ffffff !important;
}
html.dark .rounded-full.bg-brand-50,
html.dark .rounded-full.bg-emerald-50,
html.dark .rounded-full.bg-red-50 {
background-color: rgba(37, 99, 235, 0.16) !important;
color: #f8fafc !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
}
/* Dark mode sidebar hover fix */
html.dark .side-link:hover {
background: rgba(37, 99, 235, 0.16) !important;
border-color: rgba(37, 99, 235, 0.38) !important;
color: #ffffff !important;
}
html.dark .side-link:hover svg {
color: #ffffff !important;
}
html.dark .side-link.router-link-active,
html.dark .side-link.router-link-active:hover {
background: rgba(37, 99, 235, 0.22) !important;
border: 1px solid rgba(37, 99, 235, 0.45) !important;
color: #ffffff !important;
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.16) !important;
}
+4 -78
View File
@@ -1,25 +1,8 @@
<template>
<aside class="card p-4">
<div class="mb-6">
<p class="text-xs font-bold uppercase tracking-wider text-slate-500">
Menu User
</p>
<p class="text-xs font-bold uppercase tracking-wider text-slate-500">Menu User</p>
<h2 class="mt-1 text-lg font-extrabold">Dashboard</h2>
<div v-if="currentUser" class="mt-4 rounded-xl bg-slate-50 p-3">
<p class="text-sm font-bold text-slate-800">
{{ currentUser.name }}
</p>
<p class="mt-1 text-xs text-slate-500">
{{ currentUser.email }}
</p>
<p
v-if="isSuperadmin"
class="mt-2 inline-flex rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700"
>
Superadmin
</p>
</div>
</div>
<nav class="grid gap-2">
@@ -27,31 +10,18 @@
<LayoutDashboard size="18" />
Barang Saya
</RouterLink>
<RouterLink class="side-link" to="/jual">
<PlusCircle size="18" />
Jual Barang
</RouterLink>
<RouterLink class="side-link" to="/marketplace">
<Store size="18" />
Marketplace
</RouterLink>
<RouterLink class="side-link" to="/tersimpan">
<Heart size="18" />
Barang Tersimpan
</RouterLink>
<RouterLink
v-if="isSuperadmin"
class="side-link admin-link"
to="/admin/products"
>
<ShieldCheck size="18" />
Admin Marketplace
</RouterLink>
<button class="side-link text-left" type="button" @click="logout">
<LogOut size="18" />
Logout
@@ -61,66 +31,22 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import {
Heart,
LayoutDashboard,
LogOut,
PlusCircle,
ShieldCheck,
Store
} from "lucide-vue-next"
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
import { useRouter } from "vue-router"
import { Heart, LayoutDashboard, LogOut, PlusCircle, Store } from "lucide-vue-next"
import { clearAuth } from "../utils"
const router = useRouter()
const route = useRoute()
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()
router.push("/login")
}
onMounted(refreshCurrentUser)
</script>
<style scoped>
.side-link {
@apply flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-100;
}
.admin-link {
@apply bg-red-50 text-red-700 hover:bg-red-100;
}
.router-link-active {
@apply bg-brand-50 text-brand-700;
}
+16 -67
View File
@@ -2,8 +2,12 @@
<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 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>
@@ -14,21 +18,13 @@
<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>
@@ -42,31 +38,17 @@
<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>
<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>
<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>
<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>
@@ -74,18 +56,15 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from "vue"
import { 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 { Cpu, Menu } from "lucide-vue-next"
import { clearAuth, getCurrentUser } 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,
@@ -94,51 +73,21 @@ watch(
}
)
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;
}
-12
View File
@@ -108,15 +108,3 @@ export const categories = [
"Server",
"Access Point"
]
export const categoryOptions = [
{ 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" }
]
+1 -14
View File
@@ -8,8 +8,7 @@ import DashboardView from "../views/DashboardView.vue"
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 { getCurrentUser, getToken } from "../utils"
import { getToken } from "../utils"
const routes = [
{ path: "/", name: "home", component: HomeView },
@@ -18,12 +17,6 @@ const routes = [
{ path: "/jual", name: "sell-product", component: SellProductView, meta: { requiresAuth: true } },
{ path: "/dashboard", name: "dashboard", component: DashboardView, meta: { requiresAuth: true } },
{ path: "/tersimpan", name: "saved", component: SavedView, meta: { requiresAuth: true } },
{
path: "/admin/products",
name: "admin-products",
component: AdminProductsView,
meta: { requiresAuth: true, requiresSuperadmin: true }
},
{ path: "/login", name: "login", component: LoginView },
{ path: "/register", name: "register", component: RegisterView }
]
@@ -37,12 +30,6 @@ router.beforeEach((to) => {
if (to.meta.requiresAuth && !getToken()) {
return "/login"
}
const user = getCurrentUser()
if (to.meta.requiresSuperadmin && user?.role !== "superadmin") {
return "/dashboard"
}
})
export default router
+1 -18
View File
@@ -31,14 +31,7 @@ export function getCurrentUser() {
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"
})
setLocal("secondtech_user", user)
}
export function clearAuth() {
@@ -86,10 +79,6 @@ export function loginUser(payload) {
})
}
export function fetchCurrentUser() {
return requestJson("/api/auth/me")
}
export function fetchProducts() {
return requestJson("/api/products")
}
@@ -122,12 +111,6 @@ export function deleteProductById(id) {
})
}
export function deleteProductAsAdmin(id) {
return requestJson(`/api/admin/products/${id}`, {
method: "DELETE"
})
}
export function getSavedProducts() {
return getLocal("secondtech_saved", [])
}
-113
View File
@@ -1,113 +0,0 @@
<template>
<section class="container-page py-10">
<div class="mb-8">
<h1 class="text-3xl font-extrabold">Admin Marketplace</h1>
<p class="mt-2 text-slate-600">Kelola semua produk yang tampil di marketplace.</p>
</div>
<p v-if="errorMessage" class="mb-5 rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
{{ errorMessage }}
</p>
<div v-if="loading" class="card p-10 text-center">
<h2 class="text-xl font-extrabold">Memuat produk...</h2>
</div>
<div v-else-if="products.length" class="grid gap-4">
<div
v-for="product in products"
:key="product.id"
class="card grid gap-4 p-4 md:grid-cols-[140px_1fr_auto] md:items-center"
>
<img
:src="getProductImageUrl(product.image)"
:alt="product.title"
class="aspect-[4/3] w-full rounded-xl object-cover md:w-36"
/>
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700">
{{ product.category }}
</span>
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
{{ product.status }}
</span>
</div>
<h3 class="mt-3 font-extrabold">{{ product.title }}</h3>
<p class="mt-1 text-sm text-slate-500">
{{ product.seller }} {{ product.location }} {{ product.condition }}
</p>
<p class="mt-2 font-extrabold text-brand-700">
{{ formatRupiah(product.price) }}
</p>
</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="rounded-xl bg-red-50 px-3 py-2 text-sm font-bold text-red-700 hover:bg-red-100"
@click="deleteAsAdmin(product.id)"
>
Hapus
</button>
</div>
</div>
</div>
<div v-else class="card p-10 text-center">
<h2 class="text-xl font-extrabold">Belum ada produk</h2>
</div>
</section>
</template>
<script setup>
import { onMounted, ref } from "vue"
import { API_BASE_URL, fetchProducts, formatRupiah, getProductImageUrl, getToken } from "../utils"
const products = ref([])
const loading = ref(true)
const errorMessage = ref("")
async function loadProducts() {
loading.value = true
errorMessage.value = ""
try {
const data = await fetchProducts()
products.value = data.products
} catch (error) {
errorMessage.value = error.message
} finally {
loading.value = false
}
}
async function deleteAsAdmin(id) {
if (!confirm("Yakin ingin menghapus produk ini dari marketplace?")) return
try {
const response = await fetch(`${API_BASE_URL}/api/admin/products/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${getToken()}`
}
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || "Gagal menghapus produk.")
}
products.value = products.value.filter((product) => product.id !== id)
} catch (error) {
errorMessage.value = error.message
}
}
onMounted(loadProducts)
</script>
+18 -33
View File
@@ -15,17 +15,18 @@
:yoyo="true"
/>
<div>
<TextType
as="h2"
className="mt-6 text-3xl font-extrabold tracking-tight text-zinc-950 sm:text-6xl lg:text-5xl"
:text="['Menjual Barang Bekas, yang kamu butuhkan, di satu tempat.']"
:typingSpeed="75"
:pauseDuration="1500"
:showCursor="false"
:textColors="['var(--hero-title-color)']"
cursorCharacter="|"
/>
<TextType
as="h2"
className="mt-6 text-3xl font-extrabold tracking-tight text-zinc-950 sm:text-6xl lg:text-5xl"
:text="['Menjual Barang Bekas, yang kamu butuhkan, di satu tempat.']"
:typingSpeed="75"
:pauseDuration="1500"
:showCursor="false"
:textColors="['#0f1729']"
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>
<div class="mt-8 flex flex-wrap gap-3">
<RouterLink to="/jual" class="btn-primary">Jual Barang Sekarang</RouterLink>
@@ -35,7 +36,7 @@
<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="/kolase home.png" alt="Tech marketplace" />
</div>
</div>
</section>
@@ -50,12 +51,11 @@
</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" 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" />
<Cpu size="22" />
</div>
<p class="font-bold">{{ cat.name }}</p>
<p class="font-bold">{{ cat }}</p>
</div>
</div>
</section>
@@ -78,30 +78,16 @@
<script setup>
import { computed, ref } from 'vue';
import { Cpu } from 'lucide-vue-next';
import ProductCard from '../components/ProductCard.vue';
import { products, categories } from '../data/products';
import { getSavedProducts, setSavedProducts } from '../utils';
import { Boxes, Laptop, PcCase, Monitor, Keyboard, Router, Network, Server, Wifi } from 'lucide-vue-next';
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 categoryList = categoryOptions
.filter((category) => category.name !== 'Semua')
.slice(0, 6);
const categoryList = categories.filter((c) => c !== 'Semua').slice(0, 6);
const latestProducts = computed(() => products.slice(0, 4));
const iconMap = {
Boxes,
Laptop,
PcCase,
Monitor,
Keyboard,
Router,
Network,
Server,
Wifi,
};
function toggleSave(id) {
if (saved.value.includes(id)) {
@@ -112,4 +98,3 @@ function toggleSave(id) {
setSavedProducts(saved.value);
}
</script>
+2 -2
View File
@@ -1,8 +1,8 @@
<template>
<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">Selamat Datang Kembali!</h1>
<p class="mt-2 text-sm text-slate-600">Masuk dan lanjutkan jualanmu.</p>
<h1 class="text-2xl font-extrabold">Login</h1>
<p class="mt-2 text-sm text-slate-600">Masuk untuk mengelola barang jualanmu.</p>
<form class="mt-6 grid gap-4" @submit.prevent="login">
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
+6 -39
View File
@@ -1,7 +1,7 @@
<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">
@@ -9,23 +9,8 @@
</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, index) in productImages"
:key="`${image}-${index}`"
type="button"
class="h-20 w-20 shrink-0 overflow-hidden rounded-xl border-2 bg-white"
:class="selectedImage === image ? 'border-brand-600' : 'border-slate-200'"
@click="selectedImage = image"
>
<img :src="getProductImageUrl(image)" :alt="`${product.title} ${index + 1}`" class="h-full w-full object-cover" />
</button>
</div>
<div class="card order-1 overflow-hidden sm:order-2">
<img :src="getProductImageUrl(selectedImage)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
</div>
<div class="card overflow-hidden">
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
</div>
<div>
@@ -60,7 +45,7 @@
</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-WA">Hubungi via WhatsApp</a>
<button class="btn-secondary" @click="toggleSave">
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
</button>
@@ -76,7 +61,7 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from "vue"
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"
@@ -85,23 +70,6 @@ const route = useRoute()
const id = Number(route.params.id)
const product = ref(null)
const loading = ref(true)
const selectedImage = ref("")
const productImages = computed(() => {
if (!product.value) return []
const images = product.value.images?.length ? product.value.images : [product.value.image]
return [...new Set(images.filter(Boolean))]
})
watch(
productImages,
(images) => {
if (!images.includes(selectedImage.value)) {
selectedImage.value = images[0] || ""
}
},
{ immediate: true }
)
const savedIds = ref(getSavedProducts())
const saved = computed(() => savedIds.value.includes(id))
@@ -127,8 +95,7 @@ async function loadProduct() {
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 = defaultProducts.find((item) => Number(item.id) === id) || null
} finally {
loading.value = false
}
+17 -86
View File
@@ -2,7 +2,7 @@
<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 foto asli dari perangkatmu.</p>
</div>
<div class="grid gap-8 lg:grid-cols-[1fr_360px]">
@@ -19,7 +19,7 @@
<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>
<select v-model="form.category" class="input mt-2">
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
@@ -58,32 +58,9 @@
<div>
<label class="label">Foto Barang</label>
<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 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. Kamu boleh pilih beberapa sekaligus atau tambah satu per satu.
</p>
<input type="file" accept="image/*" class="input mt-2" required @change="handleImageChange" />
<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>
@@ -99,10 +76,10 @@
<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>
<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>
@@ -117,10 +94,6 @@ 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 form = reactive({
title: "",
@@ -132,8 +105,8 @@ const form = reactive({
whatsapp: "",
description: ""
})
const selectedImages = ref([])
const previewUrls = ref([])
const selectedImage = ref(null)
const previewUrl = ref("")
const loading = ref(false)
const errorMessage = ref("")
@@ -143,55 +116,15 @@ if (currentUser) {
form.whatsapp = currentUser.whatsapp || ""
}
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 = ""
const file = event.target.files?.[0]
selectedImage.value = file || null
previewUrl.value = file ? URL.createObjectURL(file) : ""
}
async function submitProduct() {
if (!selectedImages.value.length) {
errorMessage.value = "Minimal 1 foto barang wajib diupload."
if (!selectedImage.value) {
errorMessage.value = "Foto barang wajib diupload."
return
}
@@ -202,9 +135,7 @@ async function submitProduct() {
Object.entries(form).forEach(([key, value]) => {
payload.append(key, value)
})
selectedImages.value.forEach((image) => {
payload.append("images", image)
})
payload.append("image", selectedImage.value)
try {
await createProduct(payload)
-2
View File
@@ -1,6 +1,5 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: [
"./index.html",
"./src/**/*.{vue,js}"
@@ -24,4 +23,3 @@ export default {
},
plugins: []
}