fitur fitur darkmode dan foto foto

This commit is contained in:
2026-05-19 10:47:55 +07:00
parent 7e1cb94689
commit 6c36f7565e
57 changed files with 7867 additions and 236 deletions
@@ -0,0 +1,349 @@
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}`)
})
@@ -0,0 +1,137 @@
export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000"
export function formatRupiah(value) {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
maximumFractionDigits: 0
}).format(value)
}
export function getLocal(key, fallback) {
try {
const value = localStorage.getItem(key)
return value ? JSON.parse(value) : fallback
} catch {
return fallback
}
}
export function setLocal(key, value) {
localStorage.setItem(key, JSON.stringify(value))
}
export function getToken() {
return localStorage.getItem("secondtech_token")
}
export function getCurrentUser() {
return getLocal("secondtech_user", null)
}
export function setAuth({ token, user }) {
localStorage.setItem("secondtech_token", token)
setLocal("secondtech_user", {
id: user.id,
name: user.name,
email: user.email,
whatsapp: user.whatsapp,
role: user.role || "user"
})
}
export function clearAuth() {
localStorage.removeItem("secondtech_token")
localStorage.removeItem("secondtech_user")
}
export function getProductImageUrl(image) {
if (!image) return ""
if (image.startsWith("http://") || image.startsWith("https://") || image.startsWith("data:")) return image
return `${API_BASE_URL}${image}`
}
async function requestJson(path, options = {}) {
const headers = new Headers(options.headers || {})
const token = getToken()
if (token) headers.set("Authorization", `Bearer ${token}`)
if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json")
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || "Request gagal.")
}
return data
}
export function registerUser(payload) {
return requestJson("/api/auth/register", {
method: "POST",
body: JSON.stringify(payload)
})
}
export function loginUser(payload) {
return requestJson("/api/auth/login", {
method: "POST",
body: JSON.stringify(payload)
})
}
export function fetchCurrentUser() {
return requestJson("/api/auth/me")
}
export function fetchProducts() {
return requestJson("/api/products")
}
export function fetchProduct(id) {
return requestJson(`/api/products/${id}`)
}
export function fetchMyProducts() {
return requestJson("/api/products/mine")
}
export function createProduct(formData) {
return requestJson("/api/products", {
method: "POST",
body: formData
})
}
export function updateProductStatus(id, status) {
return requestJson(`/api/products/${id}/status`, {
method: "PATCH",
body: JSON.stringify({ status })
})
}
export function deleteProductById(id) {
return requestJson(`/api/products/${id}`, {
method: "DELETE"
})
}
export function deleteProductAsAdmin(id) {
return requestJson(`/api/admin/products/${id}`, {
method: "DELETE"
})
}
export function getSavedProducts() {
return getLocal("secondtech_saved", [])
}
export function setSavedProducts(ids) {
setLocal("secondtech_saved", ids)
}
@@ -0,0 +1,173 @@
<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>