Compare commits
11 Commits
e359365139
...
6c36f7565e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c36f7565e | |||
| 7e1cb94689 | |||
| 5b40053903 | |||
| 000807d646 | |||
| f118ca8489 | |||
| ee74ab393c | |||
| 0f66595775 | |||
| d855f2e75a | |||
| f7dbc170c8 | |||
| cbb8136cdd | |||
| 9b36480661 |
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS secondtech_market
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE secondtech_market;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
email VARCHAR(160) NOT NULL UNIQUE,
|
||||||
|
whatsapp VARCHAR(30) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM('user', 'superadmin') NOT NULL DEFAULT 'user',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NULL,
|
||||||
|
title VARCHAR(180) NOT NULL,
|
||||||
|
category VARCHAR(80) NOT NULL,
|
||||||
|
price INT UNSIGNED NOT NULL,
|
||||||
|
`condition` VARCHAR(80) NOT NULL,
|
||||||
|
location VARCHAR(120) NOT NULL,
|
||||||
|
seller VARCHAR(120) NOT NULL,
|
||||||
|
whatsapp VARCHAR(30) NOT NULL,
|
||||||
|
image VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
status ENUM('Tersedia', 'Terjual') NOT NULL DEFAULT 'Tersedia',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_products_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO products
|
||||||
|
(id, title, category, price, `condition`, location, seller, whatsapp, image, description, status)
|
||||||
|
VALUES
|
||||||
|
(1, 'Laptop Lenovo ThinkPad T480', 'Laptop', 3200000, 'Bekas Normal', 'Yogyakarta', 'Raka SIJA', '6281234567890', 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?q=80&w=1200&auto=format&fit=crop', 'Laptop bekas cocok untuk belajar coding, jaringan, virtual machine ringan, dan kebutuhan sekolah. RAM 8GB, SSD 256GB, keyboard normal.', 'Tersedia'),
|
||||||
|
(2, 'PC Rakitan i5 Gen 8', 'PC', 4100000, 'Bekas Normal', 'Bantul', 'Dimas Tech', '628111222333', 'https://images.unsplash.com/photo-1587202372775-e229f172b9d7?q=80&w=1200&auto=format&fit=crop', 'PC rakitan untuk lab jaringan, desain ringan, dan multitasking. Intel Core i5, RAM 16GB, SSD 512GB.', 'Tersedia'),
|
||||||
|
(3, 'Monitor LG 24 Inch IPS', 'Monitor', 1150000, 'Bekas Mulus', 'Sleman', 'Adit Hardware', '628555666777', 'https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?q=80&w=1200&auto=format&fit=crop', 'Monitor IPS 24 inch, warna masih bagus, cocok untuk coding dan editing. Include kabel power dan HDMI.', 'Tersedia'),
|
||||||
|
(4, 'Keyboard Mechanical Keychron K2', 'Keyboard', 850000, 'Bekas Normal', 'Solo', 'Naufal Keys', '628333444555', 'https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?q=80&w=1200&auto=format&fit=crop', 'Keyboard mechanical wireless, switch brown, cocok untuk coding. Kondisi normal dan keycap lengkap.', 'Tersedia'),
|
||||||
|
(5, 'MikroTik RB941 hAP Lite', 'Router', 180000, 'Bekas Normal', 'Kulon Progo', 'Lab Network', '628777888999', 'https://images.unsplash.com/photo-1606904825846-647eb07f5be2?q=80&w=1200&auto=format&fit=crop', 'Router MikroTik untuk belajar routing, firewall, DHCP, hotspot, dan konfigurasi dasar jaringan.', 'Tersedia'),
|
||||||
|
(6, 'Switch TP-Link 8 Port Gigabit', 'Switch', 250000, 'Bekas Normal', 'Magelang', 'Fajar Net', '628999111222', 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop', 'Switch 8 port gigabit, cocok untuk lab kecil, warnet mini, dan praktik jaringan lokal.', 'Tersedia'),
|
||||||
|
(7, 'Server Dell PowerEdge R620', 'Server', 6500000, 'Bekas Server Room', 'Jakarta', 'Server Bekas ID', '628222333444', 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop', 'Server rackmount bekas data center, cocok untuk belajar virtualization, Proxmox, Docker, dan homelab.', 'Tersedia'),
|
||||||
|
(8, 'Access Point TP-Link EAP225', 'Access Point', 620000, 'Bekas Mulus', 'Semarang', 'WiFi Store', '6281212121212', 'https://images.unsplash.com/photo-1544197150-b99a580bb7a8?q=80&w=1200&auto=format&fit=crop', 'Access point ceiling untuk hotspot sekolah, kantor, dan lab jaringan. Kondisi normal.', 'Tersedia')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
title = VALUES(title),
|
||||||
|
category = VALUES(category),
|
||||||
|
price = VALUES(price),
|
||||||
|
`condition` = VALUES(`condition`),
|
||||||
|
location = VALUES(location),
|
||||||
|
seller = VALUES(seller),
|
||||||
|
whatsapp = VALUES(whatsapp),
|
||||||
|
image = VALUES(image),
|
||||||
|
description = VALUES(description),
|
||||||
|
status = VALUES(status);
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import bcrypt from "bcryptjs"
|
||||||
|
import cors from "cors"
|
||||||
|
import dotenv from "dotenv"
|
||||||
|
import express from "express"
|
||||||
|
import fs from "fs"
|
||||||
|
import jwt from "jsonwebtoken"
|
||||||
|
import multer from "multer"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
import { pool } from "./db.js"
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const port = Number(process.env.PORT || 5000)
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret"
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
const uploadsDir = path.join(__dirname, "uploads")
|
||||||
|
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CLIENT_URL || "http://localhost:5173",
|
||||||
|
credentials: true
|
||||||
|
}))
|
||||||
|
app.use(express.json())
|
||||||
|
app.use("/uploads", express.static(uploadsDir))
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: uploadsDir,
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase()
|
||||||
|
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 3 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!file.mimetype.startsWith("image/")) {
|
||||||
|
cb(new Error("File harus berupa gambar."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cb(null, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function signUser(user) {
|
||||||
|
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicUser(user) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
whatsapp: user.whatsapp,
|
||||||
|
role: user.role || "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || ""
|
||||||
|
const token = header.startsWith("Bearer ") ? header.slice(7) : ""
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ message: "Silakan login terlebih dahulu." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, jwtSecret)
|
||||||
|
next()
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ message: "Sesi login tidak valid." })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findProductById(id) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const product = rows[0]
|
||||||
|
if (!product) return null
|
||||||
|
|
||||||
|
const [images] = await pool.query(
|
||||||
|
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
product.images = images.length ? images.map((item) => item.image) : [product.image]
|
||||||
|
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app.get("/api/health", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query("SELECT 1")
|
||||||
|
res.json({ status: "ok" })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/register", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, email, whatsapp, password } = req.body
|
||||||
|
|
||||||
|
if (!name || !email || !whatsapp || !password) {
|
||||||
|
res.status(400).json({ message: "Semua field wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
res.status(400).json({ message: "Password minimal 6 karakter." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10)
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)",
|
||||||
|
[name, email.toLowerCase(), whatsapp, passwordHash]
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" }
|
||||||
|
res.status(201).json({ token: signUser(user), user: publicUser(user) })
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ER_DUP_ENTRY") {
|
||||||
|
res.status(409).json({ message: "Email sudah terdaftar." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/login", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
res.status(400).json({ message: "Email dan password wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()])
|
||||||
|
const user = rows[0]
|
||||||
|
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||||
|
res.status(401).json({ message: "Email atau password salah." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ token: signUser(user), user: publicUser(user) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/auth/me", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id])
|
||||||
|
if (!rows[0]) {
|
||||||
|
res.status(404).json({ message: "User tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ user: rows[0] })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products", async (req, res, next) => {
|
||||||
|
const products = await Promise.all(
|
||||||
|
rows.map(async (product) => {
|
||||||
|
const [images] = await pool.query(
|
||||||
|
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
|
||||||
|
[product.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...product,
|
||||||
|
images: images.length ? images.map((item) => item.image) : [product.image]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ products })
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products/mine", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC",
|
||||||
|
[req.user.id]
|
||||||
|
)
|
||||||
|
res.json({ products: rows })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products/:id", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
if (!product) {
|
||||||
|
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/products", auth, upload.array("images", 6), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
||||||
|
|
||||||
|
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.files?.length) {
|
||||||
|
res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainImagePath = `/uploads/${req.files[0].filename}`
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description]
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageRows = req.files.map((file, index) => [
|
||||||
|
result.insertId,
|
||||||
|
`/uploads/${file.filename}`,
|
||||||
|
index
|
||||||
|
])
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO product_images (product_id, image, sort_order) VALUES ?",
|
||||||
|
[imageRows]
|
||||||
|
)
|
||||||
|
|
||||||
|
const product = await findProductById(result.insertId)
|
||||||
|
res.status(201).json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
app.patch("/api/products/:id/status", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia"
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE products SET status = ? WHERE id = ? AND user_id = ?",
|
||||||
|
[status, req.params.id, req.user.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.affectedRows) {
|
||||||
|
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
res.json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/api/products/:id", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
if (!product || product.user_id !== req.user.id) {
|
||||||
|
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
|
||||||
|
|
||||||
|
if (product.image.startsWith("/uploads/")) {
|
||||||
|
fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Produk berhasil dihapus." })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
|
||||||
|
|
||||||
|
if (product.image.startsWith("/uploads/")) {
|
||||||
|
fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
app.use((error, req, res, next) => {
|
||||||
|
if (error instanceof multer.MulterError) {
|
||||||
|
res.status(400).json({ message: "Upload gagal. Ukuran foto maksimal 10MB." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "File harus berupa gambar.") {
|
||||||
|
res.status(400).json({ message: error.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error)
|
||||||
|
res.status(500).json({ message: "Terjadi kesalahan pada server." })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`SecondTech API berjalan di http://localhost:${port}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async function superadminOnly(req, res, next) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT role FROM users WHERE id = ?",
|
||||||
|
[req.user.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!rows[0] || rows[0].role !== "superadmin") {
|
||||||
|
res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap");
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-slate-50 text-slate-900 font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-page {
|
||||||
|
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply rounded-2xl border border-slate-200 bg-white shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@apply text-sm font-semibold text-slate-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-WA {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||||
|
<div class="container-page flex h-16 items-center justify-between">
|
||||||
|
<RouterLink to="/" class="flex items-center gap-2">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src="/round-logo-sija.png"
|
||||||
|
alt="SecondTech Logo"
|
||||||
|
class="h-10 w-10 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<nav class="hidden items-center gap-6 md:flex">
|
||||||
|
<RouterLink class="nav-link" to="/">Home</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/marketplace">Marketplace</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/jual">Jual Barang</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/dashboard">Dashboard</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/tersimpan">Tersimpan</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-if="isSuperadmin"
|
||||||
|
class="nav-link"
|
||||||
|
to="/admin/products"
|
||||||
|
>
|
||||||
|
Admin Marketplace
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="hidden items-center gap-3 md:flex">
|
||||||
|
<template v-if="currentUser">
|
||||||
|
<span class="text-sm font-bold text-slate-700">
|
||||||
|
{{ currentUser.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="isSuperadmin"
|
||||||
|
class="rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700"
|
||||||
|
>
|
||||||
|
Superadmin
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button class="btn-secondary !px-4 !py-2" @click="logout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">
|
||||||
|
Login
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/register" class="btn-primary !px-4 !py-2">
|
||||||
|
Register
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="rounded-xl border border-slate-200 p-2 md:hidden" @click="open = !open">
|
||||||
|
<Menu />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="open" class="border-t border-slate-200 bg-white md:hidden">
|
||||||
|
<div class="container-page grid gap-2 py-4">
|
||||||
|
<RouterLink class="mobile-link" to="/" @click="open = false">Home</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/marketplace" @click="open = false">Marketplace</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/jual" @click="open = false">Jual Barang</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/dashboard" @click="open = false">Dashboard</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/tersimpan" @click="open = false">Tersimpan</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-if="isSuperadmin"
|
||||||
|
class="mobile-link"
|
||||||
|
to="/admin/products"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
Admin Marketplace
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div v-if="currentUser" class="mt-2 grid gap-2">
|
||||||
|
<div class="rounded-xl bg-slate-50 px-3 py-2">
|
||||||
|
<p class="text-sm font-bold text-slate-800">{{ currentUser.name }}</p>
|
||||||
|
<p class="text-xs text-slate-500">{{ currentUser.email }}</p>
|
||||||
|
<p v-if="isSuperadmin" class="mt-1 text-xs font-bold text-red-700">
|
||||||
|
Superadmin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-secondary !py-2 text-center" @click="logout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open = false">
|
||||||
|
Login
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">
|
||||||
|
Register
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { Cpu, Menu } from "lucide-vue-next"
|
||||||
|
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const currentUser = ref(getCurrentUser())
|
||||||
|
|
||||||
|
const isSuperadmin = computed(() => {
|
||||||
|
return currentUser.value?.role === "superadmin"
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
currentUser.value = getCurrentUser()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async function refreshCurrentUser() {
|
||||||
|
if (!getToken()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchCurrentUser()
|
||||||
|
setAuth({
|
||||||
|
token: getToken(),
|
||||||
|
user: data.user
|
||||||
|
})
|
||||||
|
currentUser.value = getCurrentUser()
|
||||||
|
} catch {
|
||||||
|
clearAuth()
|
||||||
|
currentUser.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearAuth()
|
||||||
|
currentUser.value = null
|
||||||
|
open.value = false
|
||||||
|
router.push("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshCurrentUser)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-link {
|
||||||
|
@apply text-sm font-semibold text-slate-600 transition hover:text-brand-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-link-active {
|
||||||
|
@apply text-brand-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-link {
|
||||||
|
@apply rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000"
|
||||||
|
|
||||||
|
export function formatRupiah(value) {
|
||||||
|
return new Intl.NumberFormat("id-ID", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "IDR",
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocal(key, fallback) {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(key)
|
||||||
|
return value ? JSON.parse(value) : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocal(key, value) {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem("secondtech_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentUser() {
|
||||||
|
return getLocal("secondtech_user", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAuth({ token, user }) {
|
||||||
|
localStorage.setItem("secondtech_token", token)
|
||||||
|
|
||||||
|
setLocal("secondtech_user", {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
whatsapp: user.whatsapp,
|
||||||
|
role: user.role || "user"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuth() {
|
||||||
|
localStorage.removeItem("secondtech_token")
|
||||||
|
localStorage.removeItem("secondtech_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductImageUrl(image) {
|
||||||
|
if (!image) return ""
|
||||||
|
if (image.startsWith("http://") || image.startsWith("https://") || image.startsWith("data:")) return image
|
||||||
|
return `${API_BASE_URL}${image}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(path, options = {}) {
|
||||||
|
const headers = new Headers(options.headers || {})
|
||||||
|
const token = getToken()
|
||||||
|
|
||||||
|
if (token) headers.set("Authorization", `Bearer ${token}`)
|
||||||
|
if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || "Request gagal.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUser(payload) {
|
||||||
|
return requestJson("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginUser(payload) {
|
||||||
|
return requestJson("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCurrentUser() {
|
||||||
|
return requestJson("/api/auth/me")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchProducts() {
|
||||||
|
return requestJson("/api/products")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchProduct(id) {
|
||||||
|
return requestJson(`/api/products/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMyProducts() {
|
||||||
|
return requestJson("/api/products/mine")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProduct(formData) {
|
||||||
|
return requestJson("/api/products", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProductStatus(id, status) {
|
||||||
|
return requestJson(`/api/products/${id}/status`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProductById(id) {
|
||||||
|
return requestJson(`/api/products/${id}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProductAsAdmin(id) {
|
||||||
|
return requestJson(`/api/admin/products/${id}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSavedProducts() {
|
||||||
|
return getLocal("secondtech_saved", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSavedProducts(ids) {
|
||||||
|
setLocal("secondtech_saved", ids)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container-page py-10">
|
||||||
|
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
|
||||||
|
← Kembali ke Marketplace
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card p-10 text-center">
|
||||||
|
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="product" class="grid gap-8 lg:grid-cols-2">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="rounded-full bg-brand-50 px-3 py-1 text-sm font-bold text-brand-700">
|
||||||
|
{{ product.category }}
|
||||||
|
</span>
|
||||||
|
<h1 class="mt-4 text-3xl font-extrabold text-slate-950">{{ product.title }}</h1>
|
||||||
|
<p class="mt-3 text-3xl font-extrabold text-brand-700">{{ formatRupiah(product.price) }}</p>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Kondisi</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.condition }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Lokasi</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.location }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Penjual</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.seller }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">WhatsApp</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.whatsapp }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
||||||
|
<p class="mt-2 leading-7 text-slate-600">{{ product.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
|
<a :href="waLink" target="_blank" class="btn-WA">Hubungi via WhatsApp</a>
|
||||||
|
<button class="btn-secondary" @click="toggleSave">
|
||||||
|
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card p-10 text-center">
|
||||||
|
<h1 class="text-2xl font-extrabold">Produk tidak ditemukan</h1>
|
||||||
|
<RouterLink to="/marketplace" class="btn-primary mt-5">Lihat Marketplace</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
import { products as defaultProducts } from "../data/products"
|
||||||
|
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const id = Number(route.params.id)
|
||||||
|
const product = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const savedIds = ref(getSavedProducts())
|
||||||
|
const saved = computed(() => savedIds.value.includes(id))
|
||||||
|
|
||||||
|
const waLink = computed(() => {
|
||||||
|
if (!product.value) return "#"
|
||||||
|
const text = encodeURIComponent(`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`)
|
||||||
|
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSave() {
|
||||||
|
if (saved.value) {
|
||||||
|
savedIds.value = savedIds.value.filter((item) => item !== id)
|
||||||
|
} else {
|
||||||
|
savedIds.value.push(id)
|
||||||
|
}
|
||||||
|
setSavedProducts(savedIds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProduct() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchProduct(id)
|
||||||
|
product.value = data.product
|
||||||
|
} catch {
|
||||||
|
product.value = defaultProducts.find((item) => Number(item.id) === id) || null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadProduct)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container-page py-10">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-extrabold">Jual Barang</h1>
|
||||||
|
<p class="mt-2 text-slate-600">Posting barang anda ke marketplace dengan foto produk asli.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-8 lg:grid-cols-[1fr_360px]">
|
||||||
|
<form class="card grid gap-5 p-6" @submit.prevent="submitProduct">
|
||||||
|
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Nama Barang</label>
|
||||||
|
<input v-model="form.title" class="input mt-2" placeholder="Contoh: Laptop ThinkPad T480" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="label">Kategori</label>
|
||||||
|
<select v-model="form.category" class="input mt-2">
|
||||||
|
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Harga</label>
|
||||||
|
<input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="label">Kondisi</label>
|
||||||
|
<select v-model="form.condition" class="input mt-2">
|
||||||
|
<option>Bekas Normal</option>
|
||||||
|
<option>Bekas Mulus</option>
|
||||||
|
<option>Bekas Minus</option>
|
||||||
|
<option>Bekas Server Room</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Lokasi</label>
|
||||||
|
<input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="label">Nama Penjual</label>
|
||||||
|
<input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Nomor WhatsApp</label>
|
||||||
|
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Foto Barang</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
class="input mt-2"
|
||||||
|
required
|
||||||
|
@change="handleImageChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="previewUrls.length" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<img
|
||||||
|
v-for="preview in previewUrls"
|
||||||
|
:key="preview"
|
||||||
|
:src="preview"
|
||||||
|
alt="Preview foto barang"
|
||||||
|
class="aspect-[4/3] w-full rounded-xl object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-xs text-slate-500">
|
||||||
|
Upload maksimal 6 foto. Foto pertama menjadi foto utama.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<img v-if="previewUrl" :src="previewUrl" alt="Preview foto barang" class="mt-4 aspect-[4/3] w-full max-w-sm rounded-xl object-cover" />
|
||||||
|
<p class="mt-2 text-xs text-slate-500">Upload JPG, PNG, atau WebP. Maksimal 3MB.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Deskripsi</label>
|
||||||
|
<textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Deskripsikan kondisi barang anda..." required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-primary w-full" :disabled="loading">
|
||||||
|
{{ loading ? "Memposting..." : "Posting Barang" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card h-fit p-6">
|
||||||
|
<h2 class="text-lg font-extrabold">Tips Isi Produk</h2>
|
||||||
|
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600">
|
||||||
|
<li>• Pakai judul yang jelas, misal “MikroTik RB941 Bekas Normal”.</li>
|
||||||
|
<li>• Jelaskan minus barang kalau ada.</li>
|
||||||
|
<li>• Pakai nomor WhatsApp aktif.</li>
|
||||||
|
<li>• Pakai foto yang terang dan jelas.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, computed, ref } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { categories } from "../data/products"
|
||||||
|
import { createProduct, getCurrentUser } from "../utils"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const realCategories = computed(() => categories.filter((cat) => cat !== "Semua"))
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: "",
|
||||||
|
category: "Laptop",
|
||||||
|
price: "",
|
||||||
|
condition: "Bekas Normal",
|
||||||
|
location: "",
|
||||||
|
seller: "",
|
||||||
|
whatsapp: "",
|
||||||
|
description: ""
|
||||||
|
})
|
||||||
|
const selectedImage = ref(null)
|
||||||
|
const previewUrl = ref("")
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref("")
|
||||||
|
|
||||||
|
const currentUser = getCurrentUser()
|
||||||
|
if (currentUser) {
|
||||||
|
form.seller = currentUser.name || ""
|
||||||
|
form.whatsapp = currentUser.whatsapp || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageChange(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
selectedImage.value = file || null
|
||||||
|
previewUrl.value = file ? URL.createObjectURL(file) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitProduct() {
|
||||||
|
if (!selectedImage.value) {
|
||||||
|
errorMessage.value = "Foto barang wajib diupload."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ""
|
||||||
|
|
||||||
|
const payload = new FormData()
|
||||||
|
Object.entries(form).forEach(([key, value]) => {
|
||||||
|
payload.append(key, value)
|
||||||
|
})
|
||||||
|
payload.append("image", selectedImage.value)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createProduct(payload)
|
||||||
|
router.push("/dashboard")
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js}"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Inter", "system-ui", "sans-serif"]
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: "#eff6ff",
|
||||||
|
100: "#dbeafe",
|
||||||
|
500: "#2563eb",
|
||||||
|
600: "#1d4ed8",
|
||||||
|
700: "#1e40af",
|
||||||
|
900: "#172554"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import bcrypt from "bcryptjs"
|
||||||
|
import cors from "cors"
|
||||||
|
import dotenv from "dotenv"
|
||||||
|
import express from "express"
|
||||||
|
import fs from "fs"
|
||||||
|
import jwt from "jsonwebtoken"
|
||||||
|
import multer from "multer"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
import { pool } from "./db.js"
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const port = Number(process.env.PORT || 5000)
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret"
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
const uploadsDir = path.join(__dirname, "uploads")
|
||||||
|
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CLIENT_URL || "http://localhost:5173",
|
||||||
|
credentials: true
|
||||||
|
}))
|
||||||
|
app.use(express.json())
|
||||||
|
app.use("/uploads", express.static(uploadsDir))
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: uploadsDir,
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase()
|
||||||
|
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 3 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!file.mimetype.startsWith("image/")) {
|
||||||
|
cb(new Error("File harus berupa gambar."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cb(null, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function signUser(user) {
|
||||||
|
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicUser(user) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
whatsapp: user.whatsapp,
|
||||||
|
role: user.role || "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || ""
|
||||||
|
const token = header.startsWith("Bearer ") ? header.slice(7) : ""
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ message: "Silakan login terlebih dahulu." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, jwtSecret)
|
||||||
|
next()
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ message: "Sesi login tidak valid." })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function superadminOnly(req, res, next) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id])
|
||||||
|
|
||||||
|
if (!rows[0] || rows[0].role !== "superadmin") {
|
||||||
|
res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProductImages(productId, fallbackImage) {
|
||||||
|
const [images] = await pool.query(
|
||||||
|
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
|
||||||
|
[productId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return images.length ? images.map((item) => item.image) : [fallbackImage]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachProductImages(products) {
|
||||||
|
return Promise.all(
|
||||||
|
products.map(async (product) => ({
|
||||||
|
...product,
|
||||||
|
images: await getProductImages(product.id, product.image)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findProductById(id) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const product = rows[0]
|
||||||
|
if (!product) return null
|
||||||
|
|
||||||
|
product.images = await getProductImages(product.id, product.image)
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUploadedImages(product) {
|
||||||
|
const images = product.images?.length ? product.images : [product.image]
|
||||||
|
|
||||||
|
images.forEach((image) => {
|
||||||
|
if (image?.startsWith("/uploads/")) {
|
||||||
|
fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/health", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query("SELECT 1")
|
||||||
|
res.json({ status: "ok" })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/register", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, email, whatsapp, password } = req.body
|
||||||
|
|
||||||
|
if (!name || !email || !whatsapp || !password) {
|
||||||
|
res.status(400).json({ message: "Semua field wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
res.status(400).json({ message: "Password minimal 6 karakter." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10)
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)",
|
||||||
|
[name, email.toLowerCase(), whatsapp, passwordHash]
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" }
|
||||||
|
res.status(201).json({ token: signUser(user), user: publicUser(user) })
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ER_DUP_ENTRY") {
|
||||||
|
res.status(409).json({ message: "Email sudah terdaftar." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/login", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
res.status(400).json({ message: "Email dan password wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()])
|
||||||
|
const user = rows[0]
|
||||||
|
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||||
|
res.status(401).json({ message: "Email atau password salah." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ token: signUser(user), user: publicUser(user) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/auth/me", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id])
|
||||||
|
if (!rows[0]) {
|
||||||
|
res.status(404).json({ message: "User tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ user: rows[0] })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC"
|
||||||
|
)
|
||||||
|
res.json({ products: await attachProductImages(rows) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products/mine", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC",
|
||||||
|
[req.user.id]
|
||||||
|
)
|
||||||
|
res.json({ products: await attachProductImages(rows) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products/:id", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
if (!product) {
|
||||||
|
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/products", auth, upload.array("images", 6), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
||||||
|
|
||||||
|
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.files?.length) {
|
||||||
|
res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainImagePath = `/uploads/${req.files[0].filename}`
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description]
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageRows = req.files.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index])
|
||||||
|
await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows])
|
||||||
|
|
||||||
|
const product = await findProductById(result.insertId)
|
||||||
|
res.status(201).json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/api/products/:id/status", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia"
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE products SET status = ? WHERE id = ? AND user_id = ?",
|
||||||
|
[status, req.params.id, req.user.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.affectedRows) {
|
||||||
|
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
res.json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/api/products/:id", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
if (!product || product.user_id !== req.user.id) {
|
||||||
|
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
|
||||||
|
await deleteUploadedImages(product)
|
||||||
|
|
||||||
|
res.json({ message: "Produk berhasil dihapus." })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
|
||||||
|
await deleteUploadedImages(product)
|
||||||
|
|
||||||
|
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use((error, req, res, next) => {
|
||||||
|
if (error instanceof multer.MulterError) {
|
||||||
|
res.status(400).json({ message: "Upload gagal. Ukuran setiap foto maksimal 3MB dan maksimal 6 foto." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "File harus berupa gambar.") {
|
||||||
|
res.status(400).json({ message: error.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error)
|
||||||
|
res.status(500).json({ message: "Terjadi kesalahan pada server." })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`SecondTech API berjalan di http://localhost:${port}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap");
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-slate-50 text-slate-900 font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-page {
|
||||||
|
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply rounded-2xl border border-slate-200 bg-white shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@apply text-sm font-semibold text-slate-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-WA {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SecondTech dark mode */
|
||||||
|
html.dark body {
|
||||||
|
background: #121212;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark header {
|
||||||
|
background: rgba(18, 18, 18, 0.92);
|
||||||
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .card {
|
||||||
|
background: #181818;
|
||||||
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
|
box-shadow: 0 12px 32px rgba(37, 99, 235, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input {
|
||||||
|
background: #181818;
|
||||||
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .btn-secondary {
|
||||||
|
background: #181818;
|
||||||
|
border-color: rgba(37, 99, 235, 0.32);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .bg-white,
|
||||||
|
html.dark .bg-slate-50 {
|
||||||
|
background-color: #181818;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-950,
|
||||||
|
html.dark .text-slate-900,
|
||||||
|
html.dark .text-slate-800,
|
||||||
|
html.dark .text-slate-700 {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-600,
|
||||||
|
html.dark .text-slate-500 {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .border-slate-200 {
|
||||||
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||||
|
<div class="container-page flex h-16 items-center justify-between">
|
||||||
|
<RouterLink to="/" class="flex items-center gap-2">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-600 text-white">
|
||||||
|
<Cpu size="20" />
|
||||||
|
</div>
|
||||||
|
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<nav class="hidden items-center gap-6 md:flex">
|
||||||
|
<RouterLink class="nav-link" to="/">Home</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/marketplace">Marketplace</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/jual">Jual Barang</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/dashboard">Dashboard</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/tersimpan">Tersimpan</RouterLink>
|
||||||
|
<RouterLink v-if="isSuperadmin" class="nav-link" to="/admin/products">Admin Marketplace</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="hidden items-center gap-3 md:flex">
|
||||||
|
<button type="button" class="btn-secondary !px-3 !py-2" @click="toggleTheme">
|
||||||
|
<Sun v-if="darkMode" size="18" />
|
||||||
|
<Moon v-else size="18" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-if="currentUser">
|
||||||
|
<span class="text-sm font-bold text-slate-700">{{ currentUser.name }}</span>
|
||||||
|
<span v-if="isSuperadmin" class="rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700">
|
||||||
|
Superadmin
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary !px-4 !py-2" @click="logout">Logout</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">Login</RouterLink>
|
||||||
|
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="rounded-xl border border-slate-200 p-2 md:hidden" @click="open = !open">
|
||||||
|
<Menu />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="open" class="border-t border-slate-200 bg-white md:hidden">
|
||||||
|
<div class="container-page grid gap-2 py-4">
|
||||||
|
<RouterLink class="mobile-link" to="/" @click="open = false">Home</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/marketplace" @click="open = false">Marketplace</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/jual" @click="open = false">Jual Barang</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/dashboard" @click="open = false">Dashboard</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/tersimpan" @click="open = false">Tersimpan</RouterLink>
|
||||||
|
<RouterLink v-if="isSuperadmin" class="mobile-link" to="/admin/products" @click="open = false">
|
||||||
|
Admin Marketplace
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<button type="button" class="btn-secondary mt-2 !py-2 text-center" @click="toggleTheme">
|
||||||
|
<Sun v-if="darkMode" size="18" />
|
||||||
|
<Moon v-else size="18" />
|
||||||
|
{{ darkMode ? "Light Mode" : "Dark Mode" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="currentUser" class="mt-2 grid gap-2">
|
||||||
|
<div class="rounded-xl bg-slate-50 px-3 py-2">
|
||||||
|
<p class="text-sm font-bold text-slate-800">{{ currentUser.name }}</p>
|
||||||
|
<p class="text-xs text-slate-500">{{ currentUser.email }}</p>
|
||||||
|
<p v-if="isSuperadmin" class="mt-1 text-xs font-bold text-red-700">Superadmin</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-secondary !py-2 text-center" @click="logout">Logout</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open = false">Login</RouterLink>
|
||||||
|
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">Register</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { Cpu, Menu, Moon, Sun } from "lucide-vue-next"
|
||||||
|
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const currentUser = ref(getCurrentUser())
|
||||||
|
const darkMode = ref(localStorage.getItem("secondtech_theme") === "dark")
|
||||||
|
|
||||||
|
const isSuperadmin = computed(() => currentUser.value?.role === "superadmin")
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
currentUser.value = getCurrentUser()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
document.documentElement.classList.toggle("dark", darkMode.value)
|
||||||
|
localStorage.setItem("secondtech_theme", darkMode.value ? "dark" : "light")
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
darkMode.value = !darkMode.value
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCurrentUser() {
|
||||||
|
if (!getToken()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchCurrentUser()
|
||||||
|
setAuth({ token: getToken(), user: data.user })
|
||||||
|
currentUser.value = getCurrentUser()
|
||||||
|
} catch {
|
||||||
|
clearAuth()
|
||||||
|
currentUser.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearAuth()
|
||||||
|
currentUser.value = null
|
||||||
|
open.value = false
|
||||||
|
router.push("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
applyTheme()
|
||||||
|
refreshCurrentUser()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-link {
|
||||||
|
@apply text-sm font-semibold text-slate-600 transition hover:text-brand-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-link-active {
|
||||||
|
@apply text-brand-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-link {
|
||||||
|
@apply rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,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,135 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container-page py-10">
|
||||||
|
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
|
||||||
|
← Kembali ke Marketplace
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card p-10 text-center">
|
||||||
|
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="product" class="grid gap-8 lg:grid-cols-2">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-[88px_1fr]">
|
||||||
|
<div class="order-2 flex gap-3 overflow-x-auto sm:order-1 sm:grid sm:max-h-[520px] sm:overflow-y-auto">
|
||||||
|
<button
|
||||||
|
v-for="image in productImages"
|
||||||
|
:key="image"
|
||||||
|
type="button"
|
||||||
|
class="h-20 w-20 shrink-0 overflow-hidden rounded-xl border-2 bg-white"
|
||||||
|
:class="selectedImage === image ? 'border-brand-600' : 'border-slate-200'"
|
||||||
|
@click="selectedImage = image"
|
||||||
|
>
|
||||||
|
<img :src="getProductImageUrl(image)" :alt="product.title" class="h-full w-full object-cover" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card order-1 overflow-hidden sm:order-2">
|
||||||
|
<img :src="getProductImageUrl(selectedImage)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="rounded-full bg-brand-50 px-3 py-1 text-sm font-bold text-brand-700">
|
||||||
|
{{ product.category }}
|
||||||
|
</span>
|
||||||
|
<h1 class="mt-4 text-3xl font-extrabold text-slate-950">{{ product.title }}</h1>
|
||||||
|
<p class="mt-3 text-3xl font-extrabold text-brand-700">{{ formatRupiah(product.price) }}</p>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Kondisi</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.condition }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Lokasi</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.location }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Penjual</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.seller }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">WhatsApp</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.whatsapp }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
||||||
|
<p class="mt-2 leading-7 text-slate-600">{{ product.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
|
<a :href="waLink" target="_blank" class="btn-primary">Hubungi via WhatsApp</a>
|
||||||
|
<button class="btn-secondary" @click="toggleSave">
|
||||||
|
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card p-10 text-center">
|
||||||
|
<h1 class="text-2xl font-extrabold">Produk tidak ditemukan</h1>
|
||||||
|
<RouterLink to="/marketplace" class="btn-primary mt-5">Lihat Marketplace</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
import { products as defaultProducts } from "../data/products"
|
||||||
|
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const id = Number(route.params.id)
|
||||||
|
const product = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const selectedImage = ref("")
|
||||||
|
|
||||||
|
const productImages = computed(() => {
|
||||||
|
if (!product.value) return []
|
||||||
|
return product.value.images?.length ? product.value.images : [product.value.image]
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
productImages,
|
||||||
|
(images) => {
|
||||||
|
selectedImage.value = images[0] || ""
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const savedIds = ref(getSavedProducts())
|
||||||
|
const saved = computed(() => savedIds.value.includes(id))
|
||||||
|
|
||||||
|
const waLink = computed(() => {
|
||||||
|
if (!product.value) return "#"
|
||||||
|
const text = encodeURIComponent(`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`)
|
||||||
|
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSave() {
|
||||||
|
if (saved.value) {
|
||||||
|
savedIds.value = savedIds.value.filter((item) => item !== id)
|
||||||
|
} else {
|
||||||
|
savedIds.value.push(id)
|
||||||
|
}
|
||||||
|
setSavedProducts(savedIds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProduct() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchProduct(id)
|
||||||
|
product.value = data.product
|
||||||
|
} catch {
|
||||||
|
const fallbackProduct = defaultProducts.find((item) => Number(item.id) === id) || null
|
||||||
|
product.value = fallbackProduct ? { ...fallbackProduct, images: [fallbackProduct.image] } : null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadProduct)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: "class",
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js}"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Inter", "system-ui", "sans-serif"]
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: "#eff6ff",
|
||||||
|
100: "#dbeafe",
|
||||||
|
500: "#2563eb",
|
||||||
|
600: "#1d4ed8",
|
||||||
|
700: "#1e40af",
|
||||||
|
900: "#172554"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card group overflow-hidden">
|
||||||
|
<div class="relative aspect-[4/3] overflow-hidden bg-slate-100">
|
||||||
|
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="h-full w-full object-cover transition duration-500 group-hover:scale-105" />
|
||||||
|
<span class="absolute left-3 top-3 rounded-full bg-white/90 px-3 py-1 text-xs font-bold text-slate-700">
|
||||||
|
{{ product.category }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="absolute right-3 top-3 rounded-full bg-white/90 p-2 text-slate-700 transition hover:bg-brand-600 hover:text-white"
|
||||||
|
@click.prevent="$emit('toggle-save', product.id)"
|
||||||
|
>
|
||||||
|
<Heart :fill="saved ? 'currentColor' : 'none'" size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
|
||||||
|
{{ product.condition }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-semibold text-slate-500">{{ product.location }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="line-clamp-2 min-h-[48px] text-base font-bold text-slate-900">
|
||||||
|
{{ product.title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="mt-2 text-lg font-extrabold text-brand-700">
|
||||||
|
{{ formatRupiah(product.price) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<RouterLink :to="`/produk/${product.id}`" class="btn-secondary flex-1 !px-3 !py-2 text-center">
|
||||||
|
Detail
|
||||||
|
</RouterLink>
|
||||||
|
<a :href="waLink" target="_blank" class="btn-primary flex-1 !px-3 !py-2 text-center">
|
||||||
|
Massage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { Heart } from "lucide-vue-next"
|
||||||
|
import { formatRupiah, getProductImageUrl } from "../utils"
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
product: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
saved: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(["toggle-save"])
|
||||||
|
|
||||||
|
const waLink = computed(() => {
|
||||||
|
const text = encodeURIComponent(`Halo, saya tertarik dengan ${props.product.title} di SecondTech Market.`)
|
||||||
|
return `https://wa.me/${props.product.whatsapp}?text=${text}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import bcrypt from "bcryptjs"
|
||||||
|
import cors from "cors"
|
||||||
|
import dotenv from "dotenv"
|
||||||
|
import express from "express"
|
||||||
|
import fs from "fs"
|
||||||
|
import jwt from "jsonwebtoken"
|
||||||
|
import multer from "multer"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
import { pool } from "./db.js"
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const port = Number(process.env.PORT || 5000)
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret"
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
const uploadsDir = path.join(__dirname, "uploads")
|
||||||
|
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CLIENT_URL || "http://localhost:5173",
|
||||||
|
credentials: true
|
||||||
|
}))
|
||||||
|
app.use(express.json())
|
||||||
|
app.use("/uploads", express.static(uploadsDir))
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: uploadsDir,
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase()
|
||||||
|
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 3 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!file.mimetype.startsWith("image/")) {
|
||||||
|
cb(new Error("File harus berupa gambar."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cb(null, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function signUser(user) {
|
||||||
|
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicUser(user) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
whatsapp: user.whatsapp,
|
||||||
|
role: user.role || "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || ""
|
||||||
|
const token = header.startsWith("Bearer ") ? header.slice(7) : ""
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ message: "Silakan login terlebih dahulu." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, jwtSecret)
|
||||||
|
next()
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ message: "Sesi login tidak valid." })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function superadminOnly(req, res, next) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id])
|
||||||
|
|
||||||
|
if (!rows[0] || rows[0].role !== "superadmin") {
|
||||||
|
res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProductImages(productId, fallbackImage) {
|
||||||
|
const [images] = await pool.query(
|
||||||
|
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
|
||||||
|
[productId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return images.length ? images.map((item) => item.image) : [fallbackImage]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachProductImages(products) {
|
||||||
|
return Promise.all(
|
||||||
|
products.map(async (product) => ({
|
||||||
|
...product,
|
||||||
|
images: await getProductImages(product.id, product.image)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findProductById(id) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const product = rows[0]
|
||||||
|
if (!product) return null
|
||||||
|
|
||||||
|
product.images = await getProductImages(product.id, product.image)
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUploadedImages(product) {
|
||||||
|
const images = product.images?.length ? product.images : [product.image]
|
||||||
|
|
||||||
|
images.forEach((image) => {
|
||||||
|
if (image?.startsWith("/uploads/")) {
|
||||||
|
fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/health", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query("SELECT 1")
|
||||||
|
res.json({ status: "ok" })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/register", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, email, whatsapp, password } = req.body
|
||||||
|
|
||||||
|
if (!name || !email || !whatsapp || !password) {
|
||||||
|
res.status(400).json({ message: "Semua field wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
res.status(400).json({ message: "Password minimal 6 karakter." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10)
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)",
|
||||||
|
[name, email.toLowerCase(), whatsapp, passwordHash]
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" }
|
||||||
|
res.status(201).json({ token: signUser(user), user: publicUser(user) })
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ER_DUP_ENTRY") {
|
||||||
|
res.status(409).json({ message: "Email sudah terdaftar." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/login", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
res.status(400).json({ message: "Email dan password wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()])
|
||||||
|
const user = rows[0]
|
||||||
|
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||||
|
res.status(401).json({ message: "Email atau password salah." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ token: signUser(user), user: publicUser(user) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/auth/me", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id])
|
||||||
|
if (!rows[0]) {
|
||||||
|
res.status(404).json({ message: "User tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ user: rows[0] })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC"
|
||||||
|
)
|
||||||
|
res.json({ products: await attachProductImages(rows) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products/mine", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC",
|
||||||
|
[req.user.id]
|
||||||
|
)
|
||||||
|
res.json({ products: await attachProductImages(rows) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products/:id", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
if (!product) {
|
||||||
|
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/products", auth, upload.array("images", 6), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
||||||
|
|
||||||
|
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.files?.length) {
|
||||||
|
res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainImagePath = `/uploads/${req.files[0].filename}`
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description]
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageRows = req.files.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index])
|
||||||
|
await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows])
|
||||||
|
|
||||||
|
const product = await findProductById(result.insertId)
|
||||||
|
res.status(201).json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/api/products/:id/status", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia"
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE products SET status = ? WHERE id = ? AND user_id = ?",
|
||||||
|
[status, req.params.id, req.user.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.affectedRows) {
|
||||||
|
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
res.json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/api/products/:id", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
if (!product || product.user_id !== req.user.id) {
|
||||||
|
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
|
||||||
|
await deleteUploadedImages(product)
|
||||||
|
|
||||||
|
res.json({ message: "Produk berhasil dihapus." })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
|
||||||
|
await deleteUploadedImages(product)
|
||||||
|
|
||||||
|
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use((error, req, res, next) => {
|
||||||
|
if (error instanceof multer.MulterError) {
|
||||||
|
res.status(400).json({ message: "Upload gagal. Ukuran setiap foto maksimal 3MB dan maksimal 6 foto." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "File harus berupa gambar.") {
|
||||||
|
res.status(400).json({ message: error.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error)
|
||||||
|
res.status(500).json({ message: "Terjadi kesalahan pada server." })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`SecondTech API berjalan di http://localhost:${port}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap");
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-slate-50 text-slate-900 font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-page {
|
||||||
|
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply rounded-2xl border border-slate-200 bg-white shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@apply text-sm font-semibold text-slate-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-WA {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SecondTech dark mode */
|
||||||
|
html.dark body {
|
||||||
|
background: #121212;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark header {
|
||||||
|
background: rgba(18, 18, 18, 0.92);
|
||||||
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .card {
|
||||||
|
background: #181818;
|
||||||
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
|
box-shadow: 0 12px 32px rgba(37, 99, 235, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input {
|
||||||
|
background: #181818;
|
||||||
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .btn-secondary {
|
||||||
|
background: #181818;
|
||||||
|
border-color: rgba(37, 99, 235, 0.32);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .bg-white,
|
||||||
|
html.dark .bg-slate-50 {
|
||||||
|
background-color: #181818;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-950,
|
||||||
|
html.dark .text-slate-900,
|
||||||
|
html.dark .text-slate-800,
|
||||||
|
html.dark .text-slate-700 {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-600,
|
||||||
|
html.dark .text-slate-500 {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .border-slate-200 {
|
||||||
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||||
|
<div class="container-page flex h-16 items-center justify-between">
|
||||||
|
<RouterLink to="/" class="flex items-center gap-2">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-600 text-white">
|
||||||
|
<Cpu size="20" />
|
||||||
|
</div>
|
||||||
|
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<nav class="hidden items-center gap-6 md:flex">
|
||||||
|
<RouterLink class="nav-link" to="/">Home</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/marketplace">Marketplace</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/jual">Jual Barang</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/dashboard">Dashboard</RouterLink>
|
||||||
|
<RouterLink class="nav-link" to="/tersimpan">Tersimpan</RouterLink>
|
||||||
|
<RouterLink v-if="isSuperadmin" class="nav-link" to="/admin/products">Admin Marketplace</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="hidden items-center gap-3 md:flex">
|
||||||
|
<button type="button" class="btn-secondary !px-3 !py-2" @click="toggleTheme">
|
||||||
|
<Sun v-if="darkMode" size="18" />
|
||||||
|
<Moon v-else size="18" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-if="currentUser">
|
||||||
|
<span class="text-sm font-bold text-slate-700">{{ currentUser.name }}</span>
|
||||||
|
<span v-if="isSuperadmin" class="rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700">
|
||||||
|
Superadmin
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary !px-4 !py-2" @click="logout">Logout</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">Login</RouterLink>
|
||||||
|
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="rounded-xl border border-slate-200 p-2 md:hidden" @click="open = !open">
|
||||||
|
<Menu />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="open" class="border-t border-slate-200 bg-white md:hidden">
|
||||||
|
<div class="container-page grid gap-2 py-4">
|
||||||
|
<RouterLink class="mobile-link" to="/" @click="open = false">Home</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/marketplace" @click="open = false">Marketplace</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/jual" @click="open = false">Jual Barang</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/dashboard" @click="open = false">Dashboard</RouterLink>
|
||||||
|
<RouterLink class="mobile-link" to="/tersimpan" @click="open = false">Tersimpan</RouterLink>
|
||||||
|
<RouterLink v-if="isSuperadmin" class="mobile-link" to="/admin/products" @click="open = false">
|
||||||
|
Admin Marketplace
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<button type="button" class="btn-secondary mt-2 !py-2 text-center" @click="toggleTheme">
|
||||||
|
<Sun v-if="darkMode" size="18" />
|
||||||
|
<Moon v-else size="18" />
|
||||||
|
{{ darkMode ? "Light Mode" : "Dark Mode" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="currentUser" class="mt-2 grid gap-2">
|
||||||
|
<div class="rounded-xl bg-slate-50 px-3 py-2">
|
||||||
|
<p class="text-sm font-bold text-slate-800">{{ currentUser.name }}</p>
|
||||||
|
<p class="text-xs text-slate-500">{{ currentUser.email }}</p>
|
||||||
|
<p v-if="isSuperadmin" class="mt-1 text-xs font-bold text-red-700">Superadmin</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-secondary !py-2 text-center" @click="logout">Logout</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open = false">Login</RouterLink>
|
||||||
|
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">Register</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { Cpu, Menu, Moon, Sun } from "lucide-vue-next"
|
||||||
|
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const currentUser = ref(getCurrentUser())
|
||||||
|
const darkMode = ref(localStorage.getItem("secondtech_theme") === "dark")
|
||||||
|
|
||||||
|
const isSuperadmin = computed(() => currentUser.value?.role === "superadmin")
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
currentUser.value = getCurrentUser()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
document.documentElement.classList.toggle("dark", darkMode.value)
|
||||||
|
localStorage.setItem("secondtech_theme", darkMode.value ? "dark" : "light")
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
darkMode.value = !darkMode.value
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCurrentUser() {
|
||||||
|
if (!getToken()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchCurrentUser()
|
||||||
|
setAuth({ token: getToken(), user: data.user })
|
||||||
|
currentUser.value = getCurrentUser()
|
||||||
|
} catch {
|
||||||
|
clearAuth()
|
||||||
|
currentUser.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearAuth()
|
||||||
|
currentUser.value = null
|
||||||
|
open.value = false
|
||||||
|
router.push("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
applyTheme()
|
||||||
|
refreshCurrentUser()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-link {
|
||||||
|
@apply text-sm font-semibold text-slate-600 transition hover:text-brand-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-link-active {
|
||||||
|
@apply text-brand-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-link {
|
||||||
|
@apply rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card group overflow-hidden">
|
||||||
|
<div class="relative aspect-[4/3] overflow-hidden bg-slate-100">
|
||||||
|
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="h-full w-full object-cover transition duration-500 group-hover:scale-105" />
|
||||||
|
<span class="absolute left-3 top-3 rounded-full bg-white/90 px-3 py-1 text-xs font-bold text-slate-700">
|
||||||
|
{{ product.category }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="absolute right-3 top-3 rounded-full bg-white/90 p-2 text-slate-700 transition hover:bg-brand-600 hover:text-white"
|
||||||
|
@click.prevent="$emit('toggle-save', product.id)"
|
||||||
|
>
|
||||||
|
<Heart :fill="saved ? 'currentColor' : 'none'" size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
|
||||||
|
{{ product.condition }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-semibold text-slate-500">{{ product.location }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="line-clamp-2 min-h-[48px] text-base font-bold text-slate-900">
|
||||||
|
{{ product.title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="mt-2 text-lg font-extrabold text-brand-700">
|
||||||
|
{{ formatRupiah(product.price) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<RouterLink :to="`/produk/${product.id}`" class="btn-secondary flex-1 !px-3 !py-2 text-center">
|
||||||
|
Detail
|
||||||
|
</RouterLink>
|
||||||
|
<a :href="waLink" target="_blank" class="btn-primary flex-1 !px-3 !py-2 text-center">
|
||||||
|
Massage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { Heart } from "lucide-vue-next"
|
||||||
|
import { formatRupiah, getProductImageUrl } from "../utils"
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
product: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
saved: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(["toggle-save"])
|
||||||
|
|
||||||
|
const waLink = computed(() => {
|
||||||
|
const text = encodeURIComponent(`Halo, saya tertarik dengan ${props.product.title} di SecondTech Market.`)
|
||||||
|
return `https://wa.me/${props.product.whatsapp}?text=${text}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
export const products = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Laptop Lenovo ThinkPad T480",
|
||||||
|
category: "Laptop",
|
||||||
|
price: 3200000,
|
||||||
|
condition: "Bekas Normal",
|
||||||
|
location: "Yogyakarta",
|
||||||
|
seller: "Raka SIJA",
|
||||||
|
whatsapp: "6281234567890",
|
||||||
|
image: "https://images.unsplash.com/photo-1496181133206-80ce9b88a853?q=80&w=1200&auto=format&fit=crop",
|
||||||
|
description: "Laptop bekas cocok untuk belajar coding, jaringan, virtual machine ringan, dan kebutuhan sekolah. RAM 8GB, SSD 256GB, keyboard normal."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "PC Rakitan i5 Gen 8",
|
||||||
|
category: "PC",
|
||||||
|
price: 4100000,
|
||||||
|
condition: "Bekas Normal",
|
||||||
|
location: "Bantul",
|
||||||
|
seller: "Dimas Tech",
|
||||||
|
whatsapp: "628111222333",
|
||||||
|
image: "https://images.unsplash.com/photo-1587202372775-e229f172b9d7?q=80&w=1200&auto=format&fit=crop",
|
||||||
|
description: "PC rakitan untuk lab jaringan, desain ringan, dan multitasking. Intel Core i5, RAM 16GB, SSD 512GB."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Monitor LG 24 Inch IPS",
|
||||||
|
category: "Monitor",
|
||||||
|
price: 1150000,
|
||||||
|
condition: "Bekas Mulus",
|
||||||
|
location: "Sleman",
|
||||||
|
seller: "Adit Hardware",
|
||||||
|
whatsapp: "628555666777",
|
||||||
|
image: "https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?q=80&w=1200&auto=format&fit=crop",
|
||||||
|
description: "Monitor IPS 24 inch, warna masih bagus, cocok untuk coding dan editing. Include kabel power dan HDMI."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: "Keyboard Mechanical Keychron K2",
|
||||||
|
category: "Keyboard",
|
||||||
|
price: 850000,
|
||||||
|
condition: "Bekas Normal",
|
||||||
|
location: "Solo",
|
||||||
|
seller: "Naufal Keys",
|
||||||
|
whatsapp: "628333444555",
|
||||||
|
image: "https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?q=80&w=1200&auto=format&fit=crop",
|
||||||
|
description: "Keyboard mechanical wireless, switch brown, cocok untuk coding. Kondisi normal dan keycap lengkap."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: "MikroTik RB941 hAP Lite",
|
||||||
|
category: "Router",
|
||||||
|
price: 180000,
|
||||||
|
condition: "Bekas Normal",
|
||||||
|
location: "Kulon Progo",
|
||||||
|
seller: "Lab Network",
|
||||||
|
whatsapp: "628777888999",
|
||||||
|
image: "https://images.unsplash.com/photo-1606904825846-647eb07f5be2?q=80&w=1200&auto=format&fit=crop",
|
||||||
|
description: "Router MikroTik untuk belajar routing, firewall, DHCP, hotspot, dan konfigurasi dasar jaringan."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: "Switch TP-Link 8 Port Gigabit",
|
||||||
|
category: "Switch",
|
||||||
|
price: 250000,
|
||||||
|
condition: "Bekas Normal",
|
||||||
|
location: "Magelang",
|
||||||
|
seller: "Fajar Net",
|
||||||
|
whatsapp: "628999111222",
|
||||||
|
image: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop",
|
||||||
|
description: "Switch 8 port gigabit, cocok untuk lab kecil, warnet mini, dan praktik jaringan lokal."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: "Server Dell PowerEdge R620",
|
||||||
|
category: "Server",
|
||||||
|
price: 6500000,
|
||||||
|
condition: "Bekas Server Room",
|
||||||
|
location: "Jakarta",
|
||||||
|
seller: "Server Bekas ID",
|
||||||
|
whatsapp: "628222333444",
|
||||||
|
image: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop",
|
||||||
|
description: "Server rackmount bekas data center, cocok untuk belajar virtualization, Proxmox, Docker, dan homelab."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: "Access Point TP-Link EAP225",
|
||||||
|
category: "Access Point",
|
||||||
|
price: 620000,
|
||||||
|
condition: "Bekas Mulus",
|
||||||
|
location: "Semarang",
|
||||||
|
seller: "WiFi Store",
|
||||||
|
whatsapp: "6281212121212",
|
||||||
|
image: "https://images.unsplash.com/photo-1544197150-b99a580bb7a8?q=80&w=1200&auto=format&fit=crop",
|
||||||
|
description: "Access point ceiling untuk hotspot sekolah, kantor, dan lab jaringan. Kondisi normal."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const categories = [
|
||||||
|
{
|
||||||
|
name: "Semua",
|
||||||
|
icon: "Boxes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Laptop",
|
||||||
|
icon: "Laptop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PC",
|
||||||
|
icon: "PcCase"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Monitor",
|
||||||
|
icon: "Monitor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Keyboard",
|
||||||
|
icon: "Keyboard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Router",
|
||||||
|
icon: "Router"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Switch",
|
||||||
|
icon: "Network"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Server",
|
||||||
|
icon: "Server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Access Point",
|
||||||
|
icon: "Wifi"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container-page py-10">
|
||||||
|
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
|
||||||
|
← Kembali ke Marketplace
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card p-10 text-center">
|
||||||
|
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="product" class="grid gap-8 lg:grid-cols-2">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-[88px_1fr]">
|
||||||
|
<div class="order-2 flex gap-3 overflow-x-auto sm:order-1 sm:grid sm:max-h-[520px] sm:overflow-y-auto">
|
||||||
|
<button
|
||||||
|
v-for="image in productImages"
|
||||||
|
:key="image"
|
||||||
|
type="button"
|
||||||
|
class="h-20 w-20 shrink-0 overflow-hidden rounded-xl border-2 bg-white"
|
||||||
|
:class="selectedImage === image ? 'border-brand-600' : 'border-slate-200'"
|
||||||
|
@click="selectedImage = image"
|
||||||
|
>
|
||||||
|
<img :src="getProductImageUrl(image)" :alt="product.title" class="h-full w-full object-cover" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card order-1 overflow-hidden sm:order-2">
|
||||||
|
<img :src="getProductImageUrl(selectedImage)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="rounded-full bg-brand-50 px-3 py-1 text-sm font-bold text-brand-700">
|
||||||
|
{{ product.category }}
|
||||||
|
</span>
|
||||||
|
<h1 class="mt-4 text-3xl font-extrabold text-slate-950">{{ product.title }}</h1>
|
||||||
|
<p class="mt-3 text-3xl font-extrabold text-brand-700">{{ formatRupiah(product.price) }}</p>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Kondisi</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.condition }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Lokasi</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.location }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Penjual</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.seller }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">WhatsApp</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.whatsapp }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
||||||
|
<p class="mt-2 leading-7 text-slate-600">{{ product.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
|
<a :href="waLink" target="_blank" class="btn-primary">Hubungi via WhatsApp</a>
|
||||||
|
<button class="btn-secondary" @click="toggleSave">
|
||||||
|
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card p-10 text-center">
|
||||||
|
<h1 class="text-2xl font-extrabold">Produk tidak ditemukan</h1>
|
||||||
|
<RouterLink to="/marketplace" class="btn-primary mt-5">Lihat Marketplace</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
import { products as defaultProducts } from "../data/products"
|
||||||
|
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const id = Number(route.params.id)
|
||||||
|
const product = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const selectedImage = ref("")
|
||||||
|
|
||||||
|
const productImages = computed(() => {
|
||||||
|
if (!product.value) return []
|
||||||
|
return product.value.images?.length ? product.value.images : [product.value.image]
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
productImages,
|
||||||
|
(images) => {
|
||||||
|
selectedImage.value = images[0] || ""
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const savedIds = ref(getSavedProducts())
|
||||||
|
const saved = computed(() => savedIds.value.includes(id))
|
||||||
|
|
||||||
|
const waLink = computed(() => {
|
||||||
|
if (!product.value) return "#"
|
||||||
|
const text = encodeURIComponent(`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`)
|
||||||
|
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSave() {
|
||||||
|
if (saved.value) {
|
||||||
|
savedIds.value = savedIds.value.filter((item) => item !== id)
|
||||||
|
} else {
|
||||||
|
savedIds.value.push(id)
|
||||||
|
}
|
||||||
|
setSavedProducts(savedIds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProduct() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchProduct(id)
|
||||||
|
product.value = data.product
|
||||||
|
} catch {
|
||||||
|
const fallbackProduct = defaultProducts.find((item) => Number(item.id) === id) || null
|
||||||
|
product.value = fallbackProduct ? { ...fallbackProduct, images: [fallbackProduct.image] } : null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadProduct)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container-page py-10">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-extrabold">Jual Barang</h1>
|
||||||
|
<p class="mt-2 text-slate-600">Posting barang ke marketplace dengan beberapa foto asli dari perangkatmu.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-8 lg:grid-cols-[1fr_360px]">
|
||||||
|
<form class="card grid gap-5 p-6" @submit.prevent="submitProduct">
|
||||||
|
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Nama Barang</label>
|
||||||
|
<input v-model="form.title" class="input mt-2" placeholder="Contoh: Laptop ThinkPad T480" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="label">Kategori</label>
|
||||||
|
<select v-model="form.category" class="input mt-2">
|
||||||
|
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Harga</label>
|
||||||
|
<input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="label">Kondisi</label>
|
||||||
|
<select v-model="form.condition" class="input mt-2">
|
||||||
|
<option>Bekas Normal</option>
|
||||||
|
<option>Bekas Mulus</option>
|
||||||
|
<option>Bekas Minus</option>
|
||||||
|
<option>Bekas Server Room</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Lokasi</label>
|
||||||
|
<input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="label">Nama Penjual</label>
|
||||||
|
<input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Nomor WhatsApp</label>
|
||||||
|
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Foto Barang</label>
|
||||||
|
<input type="file" accept="image/*" multiple class="input mt-2" required @change="handleImageChange" />
|
||||||
|
|
||||||
|
<div v-if="previewUrls.length" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<img
|
||||||
|
v-for="preview in previewUrls"
|
||||||
|
:key="preview"
|
||||||
|
:src="preview"
|
||||||
|
alt="Preview foto barang"
|
||||||
|
class="aspect-[4/3] w-full rounded-xl object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-xs text-slate-500">Upload maksimal 6 foto. Foto pertama menjadi foto utama.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">Deskripsi</label>
|
||||||
|
<textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Jelaskan kondisi barang..." required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-primary w-full" :disabled="loading">
|
||||||
|
{{ loading ? "Memposting..." : "Posting Barang" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card h-fit p-6">
|
||||||
|
<h2 class="text-lg font-extrabold">Tips Isi Produk</h2>
|
||||||
|
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600">
|
||||||
|
<li>- Pakai judul yang jelas, misal "MikroTik RB941 Bekas Normal".</li>
|
||||||
|
<li>- Jelaskan minus barang kalau ada.</li>
|
||||||
|
<li>- Pakai nomor WhatsApp aktif.</li>
|
||||||
|
<li>- Upload beberapa foto dari sisi yang berbeda.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, computed, ref } from "vue"
|
||||||
|
import { useRouter } from "vue-router"
|
||||||
|
import { categories } from "../data/products"
|
||||||
|
import { createProduct, getCurrentUser } from "../utils"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const realCategories = computed(() => categories.filter((cat) => cat !== "Semua"))
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: "",
|
||||||
|
category: "Laptop",
|
||||||
|
price: "",
|
||||||
|
condition: "Bekas Normal",
|
||||||
|
location: "",
|
||||||
|
seller: "",
|
||||||
|
whatsapp: "",
|
||||||
|
description: ""
|
||||||
|
})
|
||||||
|
const selectedImages = ref([])
|
||||||
|
const previewUrls = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref("")
|
||||||
|
|
||||||
|
const currentUser = getCurrentUser()
|
||||||
|
if (currentUser) {
|
||||||
|
form.seller = currentUser.name || ""
|
||||||
|
form.whatsapp = currentUser.whatsapp || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageChange(event) {
|
||||||
|
const files = Array.from(event.target.files || []).slice(0, 6)
|
||||||
|
selectedImages.value = files
|
||||||
|
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
||||||
|
previewUrls.value = files.map((file) => URL.createObjectURL(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitProduct() {
|
||||||
|
if (!selectedImages.value.length) {
|
||||||
|
errorMessage.value = "Minimal 1 foto barang wajib diupload."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ""
|
||||||
|
|
||||||
|
const payload = new FormData()
|
||||||
|
Object.entries(form).forEach(([key, value]) => {
|
||||||
|
payload.append(key, value)
|
||||||
|
})
|
||||||
|
selectedImages.value.forEach((image) => {
|
||||||
|
payload.append("images", image)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createProduct(payload)
|
||||||
|
router.push("/dashboard")
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: "class",
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js}"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Inter", "system-ui", "sans-serif"]
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: "#eff6ff",
|
||||||
|
100: "#dbeafe",
|
||||||
|
500: "#2563eb",
|
||||||
|
600: "#1d4ed8",
|
||||||
|
700: "#1e40af",
|
||||||
|
900: "#172554"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
@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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card group overflow-hidden">
|
||||||
|
<div class="relative aspect-[4/3] overflow-hidden bg-slate-100">
|
||||||
|
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="h-full w-full object-cover transition duration-500 group-hover:scale-105" />
|
||||||
|
<span class="absolute left-3 top-3 rounded-full bg-white/90 px-3 py-1 text-xs font-bold text-slate-700">
|
||||||
|
{{ product.category }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="absolute right-3 top-3 rounded-full bg-white/90 p-2 text-slate-700 transition hover:bg-brand-600 hover:text-white"
|
||||||
|
@click.prevent="$emit('toggle-save', product.id)"
|
||||||
|
>
|
||||||
|
<Heart :fill="saved ? 'currentColor' : 'none'" size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
|
||||||
|
{{ product.condition }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-semibold text-slate-500">{{ product.location }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="line-clamp-2 min-h-[48px] text-base font-bold text-slate-900">
|
||||||
|
{{ product.title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="mt-2 text-lg font-extrabold text-brand-700">
|
||||||
|
{{ formatRupiah(product.price) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<RouterLink :to="`/produk/${product.id}`" class="btn-secondary flex-1 !px-3 !py-2 text-center">
|
||||||
|
Detail
|
||||||
|
</RouterLink>
|
||||||
|
<a :href="waLink" target="_blank" class="btn-primary flex-1 !px-3 !py-2 text-center">
|
||||||
|
Massage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { Heart } from "lucide-vue-next"
|
||||||
|
import { formatRupiah, getProductImageUrl } from "../utils"
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
product: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
saved: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(["toggle-save"])
|
||||||
|
|
||||||
|
const waLink = computed(() => {
|
||||||
|
const text = encodeURIComponent(`Halo, saya tertarik dengan ${props.product.title} di SecondTech Market.`)
|
||||||
|
return `https://wa.me/${props.product.whatsapp}?text=${text}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
export const 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)
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container-page py-10">
|
||||||
|
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
|
||||||
|
← Kembali ke Marketplace
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card p-10 text-center">
|
||||||
|
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="product" class="grid gap-8 lg:grid-cols-2">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-[88px_1fr]">
|
||||||
|
<div class="order-2 flex gap-3 overflow-x-auto sm:order-1 sm:grid sm:max-h-[520px] sm:overflow-y-auto">
|
||||||
|
<button
|
||||||
|
v-for="image in productImages"
|
||||||
|
:key="image"
|
||||||
|
type="button"
|
||||||
|
class="h-20 w-20 shrink-0 overflow-hidden rounded-xl border-2 bg-white"
|
||||||
|
:class="selectedImage === image ? 'border-brand-600' : 'border-slate-200'"
|
||||||
|
@click="selectedImage = image"
|
||||||
|
>
|
||||||
|
<img :src="getProductImageUrl(image)" :alt="product.title" class="h-full w-full object-cover" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card order-1 overflow-hidden sm:order-2">
|
||||||
|
<img :src="getProductImageUrl(selectedImage)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="rounded-full bg-brand-50 px-3 py-1 text-sm font-bold text-brand-700">
|
||||||
|
{{ product.category }}
|
||||||
|
</span>
|
||||||
|
<h1 class="mt-4 text-3xl font-extrabold text-slate-950">{{ product.title }}</h1>
|
||||||
|
<p class="mt-3 text-3xl font-extrabold text-brand-700">{{ formatRupiah(product.price) }}</p>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Kondisi</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.condition }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Lokasi</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.location }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">Penjual</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.seller }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<p class="text-xs font-bold uppercase text-slate-500">WhatsApp</p>
|
||||||
|
<p class="mt-1 font-bold">{{ product.whatsapp }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
||||||
|
<p class="mt-2 leading-7 text-slate-600">{{ product.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
|
<a :href="waLink" target="_blank" class="btn-primary">Hubungi via WhatsApp</a>
|
||||||
|
<button class="btn-secondary" @click="toggleSave">
|
||||||
|
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card p-10 text-center">
|
||||||
|
<h1 class="text-2xl font-extrabold">Produk tidak ditemukan</h1>
|
||||||
|
<RouterLink to="/marketplace" class="btn-primary mt-5">Lihat Marketplace</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
import { products as defaultProducts } from "../data/products"
|
||||||
|
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const id = Number(route.params.id)
|
||||||
|
const product = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const selectedImage = ref("")
|
||||||
|
|
||||||
|
const productImages = computed(() => {
|
||||||
|
if (!product.value) return []
|
||||||
|
return product.value.images?.length ? product.value.images : [product.value.image]
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
productImages,
|
||||||
|
(images) => {
|
||||||
|
selectedImage.value = images[0] || ""
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const savedIds = ref(getSavedProducts())
|
||||||
|
const saved = computed(() => savedIds.value.includes(id))
|
||||||
|
|
||||||
|
const waLink = computed(() => {
|
||||||
|
if (!product.value) return "#"
|
||||||
|
const text = encodeURIComponent(`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`)
|
||||||
|
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSave() {
|
||||||
|
if (saved.value) {
|
||||||
|
savedIds.value = savedIds.value.filter((item) => item !== id)
|
||||||
|
} else {
|
||||||
|
savedIds.value.push(id)
|
||||||
|
}
|
||||||
|
setSavedProducts(savedIds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProduct() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchProduct(id)
|
||||||
|
product.value = data.product
|
||||||
|
} catch {
|
||||||
|
const fallbackProduct = defaultProducts.find((item) => Number(item.id) === id) || null
|
||||||
|
product.value = fallbackProduct ? { ...fallbackProduct, images: [fallbackProduct.image] } : null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadProduct)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,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>
|
||||||
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import bcrypt from "bcryptjs"
|
||||||
|
import cors from "cors"
|
||||||
|
import dotenv from "dotenv"
|
||||||
|
import express from "express"
|
||||||
|
import fs from "fs"
|
||||||
|
import jwt from "jsonwebtoken"
|
||||||
|
import multer from "multer"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
import { pool } from "./db.js"
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const port = Number(process.env.PORT || 5000)
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret"
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
const uploadsDir = path.join(__dirname, "uploads")
|
||||||
|
|
||||||
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CLIENT_URL || "http://localhost:5173",
|
||||||
|
credentials: true
|
||||||
|
}))
|
||||||
|
app.use(express.json())
|
||||||
|
app.use("/uploads", express.static(uploadsDir))
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: uploadsDir,
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase()
|
||||||
|
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 3 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!file.mimetype.startsWith("image/")) {
|
||||||
|
cb(new Error("File harus berupa gambar."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cb(null, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function signUser(user) {
|
||||||
|
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicUser(user) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
whatsapp: user.whatsapp,
|
||||||
|
role: user.role || "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || ""
|
||||||
|
const token = header.startsWith("Bearer ") ? header.slice(7) : ""
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ message: "Silakan login terlebih dahulu." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, jwtSecret)
|
||||||
|
next()
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ message: "Sesi login tidak valid." })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function superadminOnly(req, res, next) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id])
|
||||||
|
|
||||||
|
if (!rows[0] || rows[0].role !== "superadmin") {
|
||||||
|
res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProductImages(productId, fallbackImage) {
|
||||||
|
const [images] = await pool.query(
|
||||||
|
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
|
||||||
|
[productId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return images.length ? images.map((item) => item.image) : [fallbackImage]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachProductImages(products) {
|
||||||
|
return Promise.all(
|
||||||
|
products.map(async (product) => ({
|
||||||
|
...product,
|
||||||
|
images: await getProductImages(product.id, product.image)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findProductById(id) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?",
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const product = rows[0]
|
||||||
|
if (!product) return null
|
||||||
|
|
||||||
|
product.images = await getProductImages(product.id, product.image)
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUploadedImages(product) {
|
||||||
|
const images = product.images?.length ? product.images : [product.image]
|
||||||
|
|
||||||
|
images.forEach((image) => {
|
||||||
|
if (image?.startsWith("/uploads/")) {
|
||||||
|
fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/health", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query("SELECT 1")
|
||||||
|
res.json({ status: "ok" })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/register", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, email, whatsapp, password } = req.body
|
||||||
|
|
||||||
|
if (!name || !email || !whatsapp || !password) {
|
||||||
|
res.status(400).json({ message: "Semua field wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
res.status(400).json({ message: "Password minimal 6 karakter." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10)
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)",
|
||||||
|
[name, email.toLowerCase(), whatsapp, passwordHash]
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" }
|
||||||
|
res.status(201).json({ token: signUser(user), user: publicUser(user) })
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ER_DUP_ENTRY") {
|
||||||
|
res.status(409).json({ message: "Email sudah terdaftar." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/login", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
res.status(400).json({ message: "Email dan password wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()])
|
||||||
|
const user = rows[0]
|
||||||
|
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||||
|
res.status(401).json({ message: "Email atau password salah." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ token: signUser(user), user: publicUser(user) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/auth/me", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id])
|
||||||
|
if (!rows[0]) {
|
||||||
|
res.status(404).json({ message: "User tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ user: rows[0] })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC"
|
||||||
|
)
|
||||||
|
res.json({ products: await attachProductImages(rows) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products/mine", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC",
|
||||||
|
[req.user.id]
|
||||||
|
)
|
||||||
|
res.json({ products: await attachProductImages(rows) })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/products/:id", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
if (!product) {
|
||||||
|
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/products", auth, upload.array("images", 6), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
||||||
|
|
||||||
|
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.files?.length) {
|
||||||
|
res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainImagePath = `/uploads/${req.files[0].filename}`
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description]
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageRows = req.files.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index])
|
||||||
|
await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows])
|
||||||
|
|
||||||
|
const product = await findProductById(result.insertId)
|
||||||
|
res.status(201).json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/api/products/:id/status", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia"
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE products SET status = ? WHERE id = ? AND user_id = ?",
|
||||||
|
[status, req.params.id, req.user.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.affectedRows) {
|
||||||
|
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
res.json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/api/products/:id", auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
if (!product || product.user_id !== req.user.id) {
|
||||||
|
res.status(404).json({ message: "Produk milik kamu tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
|
||||||
|
await deleteUploadedImages(product)
|
||||||
|
|
||||||
|
res.json({ message: "Produk berhasil dihapus." })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
|
||||||
|
await deleteUploadedImages(product)
|
||||||
|
|
||||||
|
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use((error, req, res, next) => {
|
||||||
|
if (error instanceof multer.MulterError) {
|
||||||
|
res.status(400).json({ message: "Upload gagal. Ukuran setiap foto maksimal 3MB dan maksimal 6 foto." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === "File harus berupa gambar.") {
|
||||||
|
res.status(400).json({ message: error.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error)
|
||||||
|
res.status(500).json({ message: "Terjadi kesalahan pada server." })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`SecondTech API berjalan di http://localhost:${port}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,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>
|
||||||
|
|
||||||
@@ -56,3 +56,14 @@ ON DUPLICATE KEY UPDATE
|
|||||||
image = VALUES(image),
|
image = VALUES(image),
|
||||||
description = VALUES(description),
|
description = VALUES(description),
|
||||||
status = VALUES(status);
|
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
|
||||||
|
);
|
||||||
|
|||||||
+8
-6
@@ -15,17 +15,19 @@
|
|||||||
"cors": "latest",
|
"cors": "latest",
|
||||||
"dotenv": "latest",
|
"dotenv": "latest",
|
||||||
"express": "latest",
|
"express": "latest",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
"jsonwebtoken": "latest",
|
"jsonwebtoken": "latest",
|
||||||
|
"lucide-vue-next": "latest",
|
||||||
|
"motion-v": "^1.10.3",
|
||||||
|
"multer": "latest",
|
||||||
|
"mysql2": "latest",
|
||||||
"vite": "latest",
|
"vite": "latest",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-router": "latest",
|
"vue-router": "latest"
|
||||||
"lucide-vue-next": "latest",
|
|
||||||
"multer": "latest",
|
|
||||||
"mysql2": "latest"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tailwindcss": "^3.4.17",
|
"autoprefixer": "latest",
|
||||||
"postcss": "latest",
|
"postcss": "latest",
|
||||||
"autoprefixer": "latest"
|
"tailwindcss": "^3.4.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+98
-2
@@ -23,12 +23,18 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
|
gsap:
|
||||||
|
specifier: ^3.15.0
|
||||||
|
version: 3.15.0
|
||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
lucide-vue-next:
|
lucide-vue-next:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 1.0.0(vue@3.5.34)
|
version: 1.0.0(vue@3.5.34)
|
||||||
|
motion-v:
|
||||||
|
specifier: ^1.10.3
|
||||||
|
version: 1.10.3(@vueuse/core@14.3.0(vue@3.5.34))(vue@3.5.34)
|
||||||
multer:
|
multer:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@@ -235,6 +241,9 @@ packages:
|
|||||||
'@types/node@25.7.0':
|
'@types/node@25.7.0':
|
||||||
resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==}
|
resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==}
|
||||||
|
|
||||||
|
'@types/web-bluetooth@0.0.21':
|
||||||
|
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||||
|
|
||||||
'@vitejs/plugin-vue@6.0.6':
|
'@vitejs/plugin-vue@6.0.6':
|
||||||
resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==}
|
resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -289,6 +298,19 @@ packages:
|
|||||||
'@vue/shared@3.5.34':
|
'@vue/shared@3.5.34':
|
||||||
resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==}
|
resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==}
|
||||||
|
|
||||||
|
'@vueuse/core@14.3.0':
|
||||||
|
resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.5.0
|
||||||
|
|
||||||
|
'@vueuse/metadata@14.3.0':
|
||||||
|
resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==}
|
||||||
|
|
||||||
|
'@vueuse/shared@14.3.0':
|
||||||
|
resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.5.0
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -554,6 +576,20 @@ packages:
|
|||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
|
framer-motion@12.39.0:
|
||||||
|
resolution: {integrity: sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@emotion/is-prop-valid': '*'
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@emotion/is-prop-valid':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fresh@2.0.0:
|
fresh@2.0.0:
|
||||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -589,6 +625,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
gsap@3.15.0:
|
||||||
|
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
||||||
|
|
||||||
has-symbols@1.1.0:
|
has-symbols@1.1.0:
|
||||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -597,6 +636,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
|
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
hey-listen@1.0.8:
|
||||||
|
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
|
||||||
|
|
||||||
hookable@5.5.3:
|
hookable@5.5.3:
|
||||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||||
|
|
||||||
@@ -833,6 +875,18 @@ packages:
|
|||||||
mlly@1.8.2:
|
mlly@1.8.2:
|
||||||
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
||||||
|
|
||||||
|
motion-dom@12.39.0:
|
||||||
|
resolution: {integrity: sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==}
|
||||||
|
|
||||||
|
motion-utils@12.39.0:
|
||||||
|
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
|
||||||
|
|
||||||
|
motion-v@1.10.3:
|
||||||
|
resolution: {integrity: sha512-9Ewo/wwGv7FO3PqYJpllBF/Efc7tbeM1iinVrM73s0RUQrnXHwMZCaRX98u4lu0PQCrZghPPfCsQ14pWKIEbnQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@vueuse/core': '>=10.0.0'
|
||||||
|
vue: '>=3.0.0'
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -1398,6 +1452,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.21.0
|
undici-types: 7.21.0
|
||||||
|
|
||||||
|
'@types/web-bluetooth@0.0.21': {}
|
||||||
|
|
||||||
'@vitejs/plugin-vue@6.0.6(vite@8.0.12(@types/node@25.7.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.34)':
|
'@vitejs/plugin-vue@6.0.6(vite@8.0.12(@types/node@25.7.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.34)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.13
|
'@rolldown/pluginutils': 1.0.0-rc.13
|
||||||
@@ -1481,6 +1537,19 @@ snapshots:
|
|||||||
|
|
||||||
'@vue/shared@3.5.34': {}
|
'@vue/shared@3.5.34': {}
|
||||||
|
|
||||||
|
'@vueuse/core@14.3.0(vue@3.5.34)':
|
||||||
|
dependencies:
|
||||||
|
'@types/web-bluetooth': 0.0.21
|
||||||
|
'@vueuse/metadata': 14.3.0
|
||||||
|
'@vueuse/shared': 14.3.0(vue@3.5.34)
|
||||||
|
vue: 3.5.34
|
||||||
|
|
||||||
|
'@vueuse/metadata@14.3.0': {}
|
||||||
|
|
||||||
|
'@vueuse/shared@14.3.0(vue@3.5.34)':
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.34
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 3.0.2
|
mime-types: 3.0.2
|
||||||
@@ -1744,6 +1813,12 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
|
framer-motion@12.39.0:
|
||||||
|
dependencies:
|
||||||
|
motion-dom: 12.39.0
|
||||||
|
motion-utils: 12.39.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
fresh@2.0.0: {}
|
fresh@2.0.0: {}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
@@ -1783,12 +1858,16 @@ snapshots:
|
|||||||
|
|
||||||
gopd@1.2.0: {}
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
|
gsap@3.15.0: {}
|
||||||
|
|
||||||
has-symbols@1.1.0: {}
|
has-symbols@1.1.0: {}
|
||||||
|
|
||||||
hasown@2.0.3:
|
hasown@2.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
hey-listen@1.0.8: {}
|
||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
@@ -1980,6 +2059,24 @@ snapshots:
|
|||||||
pkg-types: 1.3.1
|
pkg-types: 1.3.1
|
||||||
ufo: 1.6.4
|
ufo: 1.6.4
|
||||||
|
|
||||||
|
motion-dom@12.39.0:
|
||||||
|
dependencies:
|
||||||
|
motion-utils: 12.39.0
|
||||||
|
|
||||||
|
motion-utils@12.39.0: {}
|
||||||
|
|
||||||
|
motion-v@1.10.3(@vueuse/core@14.3.0(vue@3.5.34))(vue@3.5.34):
|
||||||
|
dependencies:
|
||||||
|
'@vueuse/core': 14.3.0(vue@3.5.34)
|
||||||
|
framer-motion: 12.39.0
|
||||||
|
hey-listen: 1.0.8
|
||||||
|
motion-dom: 12.39.0
|
||||||
|
vue: 3.5.34
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@emotion/is-prop-valid'
|
||||||
|
- react
|
||||||
|
- react-dom
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
muggle-string@0.4.1: {}
|
muggle-string@0.4.1: {}
|
||||||
@@ -2323,8 +2420,7 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 513 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
+163
-55
@@ -14,6 +14,8 @@ dotenv.config()
|
|||||||
const app = express()
|
const app = express()
|
||||||
const port = Number(process.env.PORT || 5000)
|
const port = Number(process.env.PORT || 5000)
|
||||||
const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret"
|
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 __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
const uploadsDir = path.join(__dirname, "uploads")
|
const uploadsDir = path.join(__dirname, "uploads")
|
||||||
@@ -39,7 +41,7 @@ const storage = multer.diskStorage({
|
|||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 3 * 1024 * 1024 },
|
limits: { fileSize: maxImageSizeMb * 1024 * 1024, files: maxImageCount },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (!file.mimetype.startsWith("image/")) {
|
if (!file.mimetype.startsWith("image/")) {
|
||||||
cb(new Error("File harus berupa gambar."))
|
cb(new Error("File harus berupa gambar."))
|
||||||
@@ -49,6 +51,21 @@ 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) {
|
function signUser(user) {
|
||||||
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
|
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
|
||||||
}
|
}
|
||||||
@@ -80,12 +97,81 @@ 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) {
|
async function findProductById(id) {
|
||||||
const [rows] = await pool.query(
|
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 = ?",
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?",
|
||||||
[id]
|
[id]
|
||||||
)
|
)
|
||||||
return rows[0]
|
|
||||||
|
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) => {
|
app.get("/api/health", async (req, res, next) => {
|
||||||
@@ -97,6 +183,24 @@ 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) => {
|
app.post("/api/auth/register", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, email, whatsapp, password } = req.body
|
const { name, email, whatsapp, password } = req.body
|
||||||
@@ -169,7 +273,7 @@ app.get("/api/products", async (req, res, next) => {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC"
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC"
|
||||||
)
|
)
|
||||||
res.json({ products: rows })
|
res.json({ products: await attachProductImages(rows) })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error)
|
next(error)
|
||||||
}
|
}
|
||||||
@@ -181,7 +285,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",
|
"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]
|
[req.user.id]
|
||||||
)
|
)
|
||||||
res.json({ products: rows })
|
res.json({ products: await attachProductImages(rows) })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error)
|
next(error)
|
||||||
}
|
}
|
||||||
@@ -200,27 +304,46 @@ app.get("/api/products/:id", async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/api/products", auth, upload.single("image"), async (req, res, next) => {
|
app.post(
|
||||||
try {
|
"/api/products",
|
||||||
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
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)
|
||||||
|
|
||||||
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.file) {
|
console.log("Upload produk:", {
|
||||||
res.status(400).json({ message: "Semua field produk dan foto wajib diisi." })
|
title,
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
app.patch("/api/products/:id/status", auth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -251,10 +374,7 @@ 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 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." })
|
res.json({ message: "Produk berhasil dihapus." })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -272,10 +392,7 @@ app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
|
await pool.query("DELETE FROM products WHERE id = ?", [req.params.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 oleh superadmin." })
|
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -283,10 +400,9 @@ app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, nex
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
app.use((error, req, res, next) => {
|
app.use((error, req, res, next) => {
|
||||||
if (error instanceof multer.MulterError) {
|
if (error instanceof multer.MulterError) {
|
||||||
res.status(400).json({ message: "Upload gagal. Ukuran foto maksimal 3MB." })
|
res.status(400).json({ message: `Upload gagal. Ukuran setiap foto maksimal ${maxImageSizeMb} MB dan maksimal ${maxImageCount} foto.` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,28 +412,20 @@ app.use((error, req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
res.status(500).json({ message: "Terjadi kesalahan pada server." })
|
res.status(500).json({
|
||||||
|
message: "Terjadi kesalahan pada server.",
|
||||||
|
detail: process.env.NODE_ENV === "production" ? undefined : error.message
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.listen(port, () => {
|
ensureSchema()
|
||||||
console.log(`SecondTech API berjalan di http://localhost:${port}`)
|
.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.`)
|
||||||
async function superadminOnly(req, res, next) {
|
})
|
||||||
try {
|
})
|
||||||
const [rows] = await pool.query(
|
.catch((error) => {
|
||||||
"SELECT role FROM users WHERE id = ?",
|
console.error("Gagal menyiapkan database:", error)
|
||||||
[req.user.id]
|
process.exit(1)
|
||||||
)
|
})
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 826 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 506 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 990 KiB |
@@ -39,3 +39,461 @@ body {
|
|||||||
.btn-WA {
|
.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;
|
@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;
|
||||||
|
}
|
||||||
|
|||||||
+39
-58
@@ -14,41 +14,24 @@
|
|||||||
<RouterLink class="nav-link" to="/jual">Jual Barang</RouterLink>
|
<RouterLink class="nav-link" to="/jual">Jual Barang</RouterLink>
|
||||||
<RouterLink class="nav-link" to="/dashboard">Dashboard</RouterLink>
|
<RouterLink class="nav-link" to="/dashboard">Dashboard</RouterLink>
|
||||||
<RouterLink class="nav-link" to="/tersimpan">Tersimpan</RouterLink>
|
<RouterLink class="nav-link" to="/tersimpan">Tersimpan</RouterLink>
|
||||||
|
<RouterLink v-if="isSuperadmin" class="nav-link" to="/admin/products">Admin Marketplace</RouterLink>
|
||||||
<RouterLink
|
|
||||||
v-if="isSuperadmin"
|
|
||||||
class="nav-link"
|
|
||||||
to="/admin/products"
|
|
||||||
>
|
|
||||||
Admin Marketplace
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="hidden items-center gap-3 md:flex">
|
<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">
|
<template v-if="currentUser">
|
||||||
<span class="text-sm font-bold text-slate-700">
|
<span class="text-sm font-bold text-slate-700">{{ currentUser.name }}</span>
|
||||||
{{ currentUser.name }}
|
<span v-if="isSuperadmin" class="rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700">Superadmin</span>
|
||||||
</span>
|
<button class="btn-secondary !px-4 !py-2" @click="logout">Logout</button>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">
|
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">Login</RouterLink>
|
||||||
Login
|
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink>
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/register" class="btn-primary !px-4 !py-2">
|
|
||||||
Register
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,37 +47,26 @@
|
|||||||
<RouterLink class="mobile-link" to="/jual" @click="open = false">Jual Barang</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="/dashboard" @click="open = false">Dashboard</RouterLink>
|
||||||
<RouterLink class="mobile-link" to="/tersimpan" @click="open = false">Tersimpan</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>
|
||||||
|
|
||||||
<RouterLink
|
<button type="button" class="btn-secondary mt-2 !py-2 text-center" @click="toggleTheme">
|
||||||
v-if="isSuperadmin"
|
<Sun v-if="darkMode" size="18" />
|
||||||
class="mobile-link"
|
<Moon v-else size="18" />
|
||||||
to="/admin/products"
|
{{ darkMode ? "Light Mode" : "Dark Mode" }}
|
||||||
@click="open = false"
|
</button>
|
||||||
>
|
|
||||||
Admin Marketplace
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<div v-if="currentUser" class="mt-2 grid gap-2">
|
<div v-if="currentUser" class="mt-2 grid gap-2">
|
||||||
<div class="rounded-xl bg-slate-50 px-3 py-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-sm font-bold text-slate-800">{{ currentUser.name }}</p>
|
||||||
<p class="text-xs text-slate-500">{{ currentUser.email }}</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">
|
<p v-if="isSuperadmin" class="mt-1 text-xs font-bold text-red-700">Superadmin</p>
|
||||||
Superadmin
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn-secondary !py-2 text-center" @click="logout">Logout</button>
|
||||||
<button class="btn-secondary !py-2 text-center" @click="logout">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="mt-2 grid grid-cols-2 gap-2">
|
<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">
|
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open = false">Login</RouterLink>
|
||||||
Login
|
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">Register</RouterLink>
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">
|
|
||||||
Register
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,17 +76,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from "vue"
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { Cpu, Menu } from "lucide-vue-next"
|
import { Cpu, Menu, Moon, Sun } from "lucide-vue-next"
|
||||||
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const currentUser = ref(getCurrentUser())
|
const currentUser = ref(getCurrentUser())
|
||||||
|
const darkMode = ref(localStorage.getItem("secondtech_theme") === "dark")
|
||||||
|
|
||||||
const isSuperadmin = computed(() => {
|
const isSuperadmin = computed(() => currentUser.value?.role === "superadmin")
|
||||||
return currentUser.value?.role === "superadmin"
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
@@ -123,15 +94,22 @@ 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() {
|
async function refreshCurrentUser() {
|
||||||
if (!getToken()) return
|
if (!getToken()) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchCurrentUser()
|
const data = await fetchCurrentUser()
|
||||||
setAuth({
|
setAuth({ token: getToken(), user: data.user })
|
||||||
token: getToken(),
|
|
||||||
user: data.user
|
|
||||||
})
|
|
||||||
currentUser.value = getCurrentUser()
|
currentUser.value = getCurrentUser()
|
||||||
} catch {
|
} catch {
|
||||||
clearAuth()
|
clearAuth()
|
||||||
@@ -146,7 +124,10 @@ function logout() {
|
|||||||
router.push("/login")
|
router.push("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(refreshCurrentUser)
|
onMounted(() => {
|
||||||
|
applyTheme()
|
||||||
|
refreshCurrentUser()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -108,3 +108,15 @@ export const categories = [
|
|||||||
"Server",
|
"Server",
|
||||||
"Access Point"
|
"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" }
|
||||||
|
]
|
||||||
|
|||||||
@@ -3,4 +3,8 @@ import App from "./App.vue"
|
|||||||
import router from "./router"
|
import router from "./router"
|
||||||
import "./assets/main.css"
|
import "./assets/main.css"
|
||||||
|
|
||||||
|
if (localStorage.getItem("secondtech_theme") === "dark") {
|
||||||
|
document.documentElement.classList.add("dark")
|
||||||
|
}
|
||||||
|
|
||||||
createApp(App).use(router).mount("#app")
|
createApp(App).use(router).mount("#app")
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ async function requestJson(path, options = {}) {
|
|||||||
...options,
|
...options,
|
||||||
headers
|
headers
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
+59
-35
@@ -2,15 +2,31 @@
|
|||||||
<section class="bg-gradient-to-br from-brand-50 via-white to-slate-100">
|
<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 class="container-page grid min-h-[560px] items-center gap-10 py-16 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<span class="rounded-full bg-brand-100 px-4 py-2 text-sm font-bold text-brand-700">
|
<ShinyText
|
||||||
Marketplace Barang Bekas Teknologi
|
text="SecondTech"
|
||||||
</span>
|
className="text-1xl font-extrabold tracking-tight sm:text-1xl lg:text-2xl"
|
||||||
<h1 class="mt-6 text-4xl font-extrabold tracking-tight text-slate-950 sm:text-5xl lg:text-6xl">
|
:color="'#1c4ed9'"
|
||||||
Jual beli perangkat IT bekas jadi lebih mudah.
|
:shineColor="'#e0e7ff'"
|
||||||
</h1>
|
:spread="45"
|
||||||
<p class="mt-6 max-w-xl text-lg leading-8 text-slate-600">
|
:speed="1"
|
||||||
Temukan laptop bekas, PC, monitor, keyboard, router, switch, access point, dan server bekas untuk kebutuhan belajar SIJA/TKJ.
|
:delay="2"
|
||||||
</p>
|
: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="['var(--hero-title-color)']"
|
||||||
|
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">
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
<RouterLink to="/jual" class="btn-primary">Jual Barang Sekarang</RouterLink>
|
<RouterLink to="/jual" class="btn-primary">Jual Barang Sekarang</RouterLink>
|
||||||
<RouterLink to="/marketplace" class="btn-secondary">Lihat Marketplace</RouterLink>
|
<RouterLink to="/marketplace" class="btn-secondary">Lihat Marketplace</RouterLink>
|
||||||
@@ -19,11 +35,7 @@
|
|||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute -inset-4 rounded-[2rem] bg-brand-100 blur-2xl"></div>
|
<div class="absolute -inset-4 rounded-[2rem] bg-brand-100 blur-2xl"></div>
|
||||||
<img
|
<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" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -38,11 +50,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<div v-for="cat in categoryList" :key="cat" class="card p-5 text-center transition hover:-translate-y-1 hover:shadow-md">
|
<div v-for="cat in categoryList" :key="cat.name" class="card p-5 text-center transition hover:-translate-y-1 hover:shadow-md">
|
||||||
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-700">
|
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-700">
|
||||||
<Cpu size="22" />
|
<component :is="iconMap[cat.icon]" :size="22" />
|
||||||
</div>
|
</div>
|
||||||
<p class="font-bold">{{ cat }}</p>
|
|
||||||
|
<p class="font-bold">{{ cat.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -57,35 +70,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<ProductCard
|
<ProductCard v-for="product in latestProducts" :key="product.id" :product="product" :saved="saved.includes(product.id)" @toggle-save="toggleSave" />
|
||||||
v-for="product in latestProducts"
|
|
||||||
:key="product.id"
|
|
||||||
:product="product"
|
|
||||||
:saved="saved.includes(product.id)"
|
|
||||||
@toggle-save="toggleSave"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from "vue"
|
import { computed, ref } from 'vue';
|
||||||
import { Cpu } from "lucide-vue-next"
|
import ProductCard from '../components/ProductCard.vue';
|
||||||
import ProductCard from "../components/ProductCard.vue"
|
import { getSavedProducts, setSavedProducts } from '../utils';
|
||||||
import { products, categories } from "../data/products"
|
import { Boxes, Laptop, PcCase, Monitor, Keyboard, Router, Network, Server, Wifi } from 'lucide-vue-next';
|
||||||
import { getSavedProducts, setSavedProducts } from "../utils"
|
|
||||||
|
|
||||||
const saved = ref(getSavedProducts())
|
import { products, categoryOptions } from '../data/products';
|
||||||
const categoryList = categories.filter((c) => c !== "Semua").slice(0, 6)
|
import ShinyText from '../vuebits/ShinyText/ShinyText.vue';
|
||||||
const latestProducts = computed(() => products.slice(0, 4))
|
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) {
|
function toggleSave(id) {
|
||||||
if (saved.value.includes(id)) {
|
if (saved.value.includes(id)) {
|
||||||
saved.value = saved.value.filter((item) => item !== id)
|
saved.value = saved.value.filter((item) => item !== id);
|
||||||
} else {
|
} else {
|
||||||
saved.value.push(id)
|
saved.value.push(id);
|
||||||
}
|
}
|
||||||
setSavedProducts(saved.value)
|
setSavedProducts(saved.value);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="container-page grid min-h-[70vh] place-items-center py-10">
|
<section class="container-page grid min-h-[70vh] place-items-center py-10">
|
||||||
<div class="card w-full max-w-md p-6">
|
<div class="card w-full max-w-md p-6">
|
||||||
<h1 class="text-2xl font-extrabold">Login</h1>
|
<h1 class="text-2xl font-extrabold">Selamat Datang Kembali!</h1>
|
||||||
<p class="mt-2 text-sm text-slate-600">Masuk untuk mengelola barang jualanmu.</p>
|
<p class="mt-2 text-sm text-slate-600">Masuk dan lanjutkan jualanmu.</p>
|
||||||
|
|
||||||
<form class="mt-6 grid gap-4" @submit.prevent="login">
|
<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">
|
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="container-page py-10">
|
<section class="container-page py-10">
|
||||||
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
|
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
|
||||||
← Kembali ke Marketplace
|
<- Kembali ke Marketplace
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<div v-if="loading" class="card p-10 text-center">
|
<div v-if="loading" class="card p-10 text-center">
|
||||||
@@ -9,8 +9,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="product" class="grid gap-8 lg:grid-cols-2">
|
<div v-else-if="product" class="grid gap-8 lg:grid-cols-2">
|
||||||
<div class="card overflow-hidden">
|
<div class="grid gap-4 sm:grid-cols-[88px_1fr]">
|
||||||
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -61,7 +76,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from "vue"
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
import { useRoute } from "vue-router"
|
import { useRoute } from "vue-router"
|
||||||
import { products as defaultProducts } from "../data/products"
|
import { products as defaultProducts } from "../data/products"
|
||||||
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
|
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
|
||||||
@@ -70,6 +85,23 @@ const route = useRoute()
|
|||||||
const id = Number(route.params.id)
|
const id = Number(route.params.id)
|
||||||
const product = ref(null)
|
const product = ref(null)
|
||||||
const loading = ref(true)
|
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 savedIds = ref(getSavedProducts())
|
||||||
const saved = computed(() => savedIds.value.includes(id))
|
const saved = computed(() => savedIds.value.includes(id))
|
||||||
@@ -95,7 +127,8 @@ async function loadProduct() {
|
|||||||
const data = await fetchProduct(id)
|
const data = await fetchProduct(id)
|
||||||
product.value = data.product
|
product.value = data.product
|
||||||
} catch {
|
} catch {
|
||||||
product.value = defaultProducts.find((item) => Number(item.id) === id) || null
|
const fallbackProduct = defaultProducts.find((item) => Number(item.id) === id) || null
|
||||||
|
product.value = fallbackProduct ? { ...fallbackProduct, images: [fallbackProduct.image] } : null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<section class="container-page py-10">
|
<section class="container-page py-10">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-extrabold">Jual Barang</h1>
|
<h1 class="text-3xl font-extrabold">Jual Barang</h1>
|
||||||
<p class="mt-2 text-slate-600">Posting barang ke marketplace dengan foto asli dari perangkatmu.</p>
|
<p class="mt-2 text-slate-600">Posting barang ke marketplace dengan beberapa foto asli dari perangkatmu.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-8 lg:grid-cols-[1fr_360px]">
|
<div class="grid gap-8 lg:grid-cols-[1fr_360px]">
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="grid gap-5 sm:grid-cols-2">
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="label">Kategori</label>
|
<label class="label">Kategori</label>
|
||||||
<select v-model="form.category" class="input mt-2">
|
<select v-model="form.category" class="input mt-2" required>
|
||||||
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
|
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,9 +58,32 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="label">Foto Barang</label>
|
<label class="label">Foto Barang</label>
|
||||||
<input type="file" accept="image/*" class="input mt-2" required @change="handleImageChange" />
|
<input
|
||||||
<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" />
|
ref="fileInput"
|
||||||
<p class="mt-2 text-xs text-slate-500">Upload JPG, PNG, atau WebP. Maksimal 3MB.</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -76,10 +99,10 @@
|
|||||||
<div class="card h-fit p-6">
|
<div class="card h-fit p-6">
|
||||||
<h2 class="text-lg font-extrabold">Tips Isi Produk</h2>
|
<h2 class="text-lg font-extrabold">Tips Isi Produk</h2>
|
||||||
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600">
|
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600">
|
||||||
<li>• Pakai judul yang jelas, misal “MikroTik RB941 Bekas Normal”.</li>
|
<li>- Pakai judul yang jelas, misal "MikroTik RB941 Bekas Normal".</li>
|
||||||
<li>• Jelaskan minus barang kalau ada.</li>
|
<li>- Jelaskan minus barang kalau ada.</li>
|
||||||
<li>• Pakai nomor WhatsApp aktif.</li>
|
<li>- Pakai nomor WhatsApp aktif.</li>
|
||||||
<li>• Pakai foto yang terang dan jelas.</li>
|
<li>- Upload beberapa foto dari sisi yang berbeda.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,6 +117,10 @@ import { createProduct, getCurrentUser } from "../utils"
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const realCategories = computed(() => categories.filter((cat) => cat !== "Semua"))
|
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({
|
const form = reactive({
|
||||||
title: "",
|
title: "",
|
||||||
@@ -105,8 +132,8 @@ const form = reactive({
|
|||||||
whatsapp: "",
|
whatsapp: "",
|
||||||
description: ""
|
description: ""
|
||||||
})
|
})
|
||||||
const selectedImage = ref(null)
|
const selectedImages = ref([])
|
||||||
const previewUrl = ref("")
|
const previewUrls = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref("")
|
const errorMessage = ref("")
|
||||||
|
|
||||||
@@ -116,15 +143,55 @@ if (currentUser) {
|
|||||||
form.whatsapp = currentUser.whatsapp || ""
|
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) {
|
function handleImageChange(event) {
|
||||||
const file = event.target.files?.[0]
|
const incomingFiles = Array.from(event.target.files || [])
|
||||||
selectedImage.value = file || null
|
const oversizedFile = incomingFiles.find((file) => file.size > MAX_IMAGE_SIZE)
|
||||||
previewUrl.value = file ? URL.createObjectURL(file) : ""
|
|
||||||
|
if (oversizedFile) {
|
||||||
|
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`
|
||||||
|
event.target.value = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = [...selectedImages.value]
|
||||||
|
incomingFiles.forEach((file) => {
|
||||||
|
if (!merged.some((item) => imageKey(item) === imageKey(file)) && merged.length < MAX_IMAGES) {
|
||||||
|
merged.push(file)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
selectedImages.value = merged
|
||||||
|
rebuildPreviews()
|
||||||
|
errorMessage.value = ""
|
||||||
|
event.target.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(index) {
|
||||||
|
selectedImages.value = selectedImages.value.filter((_, itemIndex) => itemIndex !== index)
|
||||||
|
rebuildPreviews()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImages() {
|
||||||
|
selectedImages.value = []
|
||||||
|
rebuildPreviews()
|
||||||
|
if (fileInput.value) fileInput.value.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitProduct() {
|
async function submitProduct() {
|
||||||
if (!selectedImage.value) {
|
if (!selectedImages.value.length) {
|
||||||
errorMessage.value = "Foto barang wajib diupload."
|
errorMessage.value = "Minimal 1 foto barang wajib diupload."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +202,9 @@ async function submitProduct() {
|
|||||||
Object.entries(form).forEach(([key, value]) => {
|
Object.entries(form).forEach(([key, value]) => {
|
||||||
payload.append(key, value)
|
payload.append(key, value)
|
||||||
})
|
})
|
||||||
payload.append("image", selectedImage.value)
|
selectedImages.value.forEach((image) => {
|
||||||
|
payload.append("images", image)
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createProduct(payload)
|
await createProduct(payload)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
interface ShinyTextProps {
|
||||||
|
text: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
speed?: number;
|
||||||
|
className?: string;
|
||||||
|
color?: string;
|
||||||
|
shineColor?: string;
|
||||||
|
spread?: number;
|
||||||
|
yoyo?: boolean;
|
||||||
|
pauseOnHover?: boolean;
|
||||||
|
direction?: 'left' | 'right';
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ShinyTextProps>(), {
|
||||||
|
disabled: false,
|
||||||
|
speed: 2,
|
||||||
|
className: '',
|
||||||
|
color: '#b5b5b5',
|
||||||
|
shineColor: '#ffffff',
|
||||||
|
spread: 120,
|
||||||
|
yoyo: false,
|
||||||
|
pauseOnHover: false,
|
||||||
|
direction: 'left',
|
||||||
|
delay: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPaused = ref(false);
|
||||||
|
const progress = useMotionValue(0);
|
||||||
|
const elapsedRef = ref(0);
|
||||||
|
const lastTimeRef = ref<number | null>(null);
|
||||||
|
const directionRef = ref(props.direction === 'left' ? 1 : -1);
|
||||||
|
|
||||||
|
const animationDuration = computed(() => props.speed * 1000);
|
||||||
|
const delayDuration = computed(() => props.delay * 1000);
|
||||||
|
|
||||||
|
useAnimationFrame(time => {
|
||||||
|
if (props.disabled || isPaused.value) {
|
||||||
|
lastTimeRef.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastTimeRef.value === null) {
|
||||||
|
lastTimeRef.value = time;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaTime = time - lastTimeRef.value;
|
||||||
|
lastTimeRef.value = time;
|
||||||
|
|
||||||
|
elapsedRef.value += deltaTime;
|
||||||
|
|
||||||
|
// Animation goes from 0 to 100
|
||||||
|
if (props.yoyo) {
|
||||||
|
const cycleDuration = animationDuration.value + delayDuration.value;
|
||||||
|
const fullCycle = cycleDuration * 2;
|
||||||
|
const cycleTime = elapsedRef.value % fullCycle;
|
||||||
|
|
||||||
|
if (cycleTime < animationDuration.value) {
|
||||||
|
// Forward animation: 0 -> 100
|
||||||
|
const p = (cycleTime / animationDuration.value) * 100;
|
||||||
|
progress.set(directionRef.value === 1 ? p : 100 - p);
|
||||||
|
} else if (cycleTime < cycleDuration) {
|
||||||
|
// Delay at end
|
||||||
|
progress.set(directionRef.value === 1 ? 100 : 0);
|
||||||
|
} else if (cycleTime < cycleDuration + animationDuration.value) {
|
||||||
|
// Reverse animation: 100 -> 0
|
||||||
|
const reverseTime = cycleTime - cycleDuration;
|
||||||
|
const p = 100 - (reverseTime / animationDuration.value) * 100;
|
||||||
|
progress.set(directionRef.value === 1 ? p : 100 - p);
|
||||||
|
} else {
|
||||||
|
// Delay at start
|
||||||
|
progress.set(directionRef.value === 1 ? 0 : 100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const cycleDuration = animationDuration.value + delayDuration.value;
|
||||||
|
const cycleTime = elapsedRef.value % cycleDuration;
|
||||||
|
|
||||||
|
if (cycleTime < animationDuration.value) {
|
||||||
|
// Animation phase: 0 -> 100
|
||||||
|
const p = (cycleTime / animationDuration.value) * 100;
|
||||||
|
progress.set(directionRef.value === 1 ? p : 100 - p);
|
||||||
|
} else {
|
||||||
|
// Delay phase - hold at end (shine off-screen)
|
||||||
|
progress.set(directionRef.value === 1 ? 100 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.direction,
|
||||||
|
() => {
|
||||||
|
directionRef.value = props.direction === 'left' ? 1 : -1;
|
||||||
|
elapsedRef.value = 0;
|
||||||
|
progress.set(0);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`);
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (props.pauseOnHover) isPaused.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (props.pauseOnHover) isPaused.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gradientStyle = computed(() => ({
|
||||||
|
backgroundImage: `linear-gradient(${props.spread}deg, ${props.color} 0%, ${props.color} 35%, ${props.shineColor} 50%, ${props.color} 65%, ${props.color} 100%)`,
|
||||||
|
backgroundSize: '200% auto',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent'
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Motion
|
||||||
|
tag="span"
|
||||||
|
:class="['inline-block', className]"
|
||||||
|
:style="{ ...gradientStyle, backgroundPosition }"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
>
|
||||||
|
{{ text }}
|
||||||
|
</Motion>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch, computed, useTemplateRef } from 'vue';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
|
||||||
|
interface TextTypeProps {
|
||||||
|
className?: string;
|
||||||
|
showCursor?: boolean;
|
||||||
|
hideCursorWhileTyping?: boolean;
|
||||||
|
cursorCharacter?: string;
|
||||||
|
cursorBlinkDuration?: number;
|
||||||
|
cursorClassName?: string;
|
||||||
|
text: string | string[];
|
||||||
|
as?: string;
|
||||||
|
typingSpeed?: number;
|
||||||
|
initialDelay?: number;
|
||||||
|
pauseDuration?: number;
|
||||||
|
deletingSpeed?: number;
|
||||||
|
loop?: boolean;
|
||||||
|
textColors?: string[];
|
||||||
|
variableSpeed?: { min: number; max: number };
|
||||||
|
onSentenceComplete?: (sentence: string, index: number) => void;
|
||||||
|
startOnVisible?: boolean;
|
||||||
|
reverseMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TextTypeProps>(), {
|
||||||
|
as: 'div',
|
||||||
|
typingSpeed: 50,
|
||||||
|
initialDelay: 0,
|
||||||
|
pauseDuration: 2000,
|
||||||
|
deletingSpeed: 30,
|
||||||
|
loop: true,
|
||||||
|
className: '',
|
||||||
|
showCursor: true,
|
||||||
|
hideCursorWhileTyping: false,
|
||||||
|
cursorCharacter: '|',
|
||||||
|
cursorBlinkDuration: 0.5,
|
||||||
|
textColors: () => [],
|
||||||
|
startOnVisible: false,
|
||||||
|
reverseMode: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayedText = ref('');
|
||||||
|
const currentCharIndex = ref(0);
|
||||||
|
const isDeleting = ref(false);
|
||||||
|
const currentTextIndex = ref(0);
|
||||||
|
const isVisible = ref(!props.startOnVisible);
|
||||||
|
const cursorRef = useTemplateRef('cursorRef');
|
||||||
|
const containerRef = useTemplateRef('containerRef');
|
||||||
|
|
||||||
|
const textArray = computed(() => (Array.isArray(props.text) ? props.text : [props.text]));
|
||||||
|
|
||||||
|
const getRandomSpeed = () => {
|
||||||
|
if (!props.variableSpeed) return props.typingSpeed;
|
||||||
|
const { min, max } = props.variableSpeed;
|
||||||
|
return Math.random() * (max - min) + min;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentTextColor = () => {
|
||||||
|
if (!props.textColors.length) return '#ffffff';
|
||||||
|
return props.textColors[currentTextIndex.value % props.textColors.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const clearTimeoutIfNeeded = () => {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeTypingAnimation = () => {
|
||||||
|
const currentText = textArray.value[currentTextIndex.value];
|
||||||
|
const processedText = props.reverseMode ? currentText.split('').reverse().join('') : currentText;
|
||||||
|
|
||||||
|
if (isDeleting.value) {
|
||||||
|
if (displayedText.value === '') {
|
||||||
|
isDeleting.value = false;
|
||||||
|
if (currentTextIndex.value === textArray.value.length - 1 && !props.loop) return;
|
||||||
|
|
||||||
|
props.onSentenceComplete?.(textArray.value[currentTextIndex.value], currentTextIndex.value);
|
||||||
|
|
||||||
|
currentTextIndex.value = (currentTextIndex.value + 1) % textArray.value.length;
|
||||||
|
currentCharIndex.value = 0;
|
||||||
|
timeout = setTimeout(() => {}, props.pauseDuration);
|
||||||
|
} else {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
displayedText.value = displayedText.value.slice(0, -1);
|
||||||
|
}, props.deletingSpeed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentCharIndex.value < processedText.length) {
|
||||||
|
timeout = setTimeout(
|
||||||
|
() => {
|
||||||
|
displayedText.value += processedText[currentCharIndex.value];
|
||||||
|
currentCharIndex.value += 1;
|
||||||
|
},
|
||||||
|
props.variableSpeed ? getRandomSpeed() : props.typingSpeed
|
||||||
|
);
|
||||||
|
} else if (textArray.value.length > 1) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
isDeleting.value = true;
|
||||||
|
}, props.pauseDuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[displayedText, currentCharIndex, isDeleting, isVisible],
|
||||||
|
() => {
|
||||||
|
if (!isVisible.value) return;
|
||||||
|
clearTimeoutIfNeeded();
|
||||||
|
|
||||||
|
if (currentCharIndex.value === 0 && !isDeleting.value && displayedText.value === '') {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
executeTypingAnimation();
|
||||||
|
}, props.initialDelay);
|
||||||
|
} else {
|
||||||
|
executeTypingAnimation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.showCursor && cursorRef.value) {
|
||||||
|
gsap.set(cursorRef.value, { opacity: 1 });
|
||||||
|
gsap.to(cursorRef.value, {
|
||||||
|
opacity: 0,
|
||||||
|
duration: props.cursorBlinkDuration,
|
||||||
|
repeat: -1,
|
||||||
|
yoyo: true,
|
||||||
|
ease: 'power2.inOut'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.startOnVisible && containerRef.value) {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
entries => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) isVisible.value = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
if (containerRef.value instanceof Element) {
|
||||||
|
observer.observe(containerRef.value);
|
||||||
|
}
|
||||||
|
onBeforeUnmount(() => observer.disconnect());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeoutIfNeeded();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
ref="containerRef"
|
||||||
|
:class="`inline-block whitespace-pre-wrap tracking-tight ${className}`"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<span class="inline" :style="{ color: getCurrentTextColor() }">
|
||||||
|
{{ displayedText }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="showCursor"
|
||||||
|
ref="cursorRef"
|
||||||
|
:class="`ml-1 inline-block opacity-100 ${
|
||||||
|
hideCursorWhileTyping && (currentCharIndex < textArray[currentTextIndex].length || isDeleting) ? 'hidden' : ''
|
||||||
|
} ${cursorClassName}`"
|
||||||
|
>
|
||||||
|
{{ cursorCharacter }}
|
||||||
|
</span>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: "class",
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{vue,js}"
|
"./src/**/*.{vue,js}"
|
||||||
@@ -23,3 +24,4 @@ export default {
|
|||||||
},
|
},
|
||||||
plugins: []
|
plugins: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user