Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da92e21c2a |
@@ -1,10 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS product_images (
|
|
||||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
product_id INT UNSIGNED NOT NULL,
|
|
||||||
image VARCHAR(255) NOT NULL,
|
|
||||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT fk_product_images_product
|
|
||||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
@@ -10,7 +10,6 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
email VARCHAR(160) NOT NULL UNIQUE,
|
email VARCHAR(160) NOT NULL UNIQUE,
|
||||||
whatsapp VARCHAR(30) NOT NULL,
|
whatsapp VARCHAR(30) NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
role ENUM('user', 'superadmin') NOT NULL DEFAULT 'user',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,14 +55,3 @@ 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
|
|
||||||
);
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
+21
-171
@@ -14,8 +14,6 @@ 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")
|
||||||
@@ -41,7 +39,7 @@ const storage = multer.diskStorage({
|
|||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: maxImageSizeMb * 1024 * 1024, files: maxImageCount },
|
limits: { fileSize: 3 * 1024 * 1024 },
|
||||||
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."))
|
||||||
@@ -51,21 +49,6 @@ const upload = multer({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function ensureSchema() {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS product_images (
|
|
||||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
product_id INT UNSIGNED NOT NULL,
|
|
||||||
image VARCHAR(255) NOT NULL,
|
|
||||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT fk_product_images_product
|
|
||||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function signUser(user) {
|
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" })
|
||||||
}
|
}
|
||||||
@@ -75,8 +58,7 @@ function publicUser(user) {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
whatsapp: user.whatsapp,
|
whatsapp: user.whatsapp
|
||||||
role: user.role || "user"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,81 +79,12 @@ function auth(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function superadminOnly(req, res, next) {
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id])
|
|
||||||
|
|
||||||
if (!rows[0] || rows[0].role !== "superadmin") {
|
|
||||||
res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next()
|
|
||||||
} catch (error) {
|
|
||||||
next(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueImages(files) {
|
|
||||||
const seen = new Set()
|
|
||||||
return files.filter((file) => {
|
|
||||||
const key = `${file.originalname}-${file.size}-${file.mimetype}`
|
|
||||||
if (seen.has(key)) return false
|
|
||||||
seen.add(key)
|
|
||||||
return true
|
|
||||||
}).slice(0, maxImageCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUploadedImages(req) {
|
|
||||||
const files = [
|
|
||||||
...(req.files?.images || []),
|
|
||||||
...(req.files?.image || []),
|
|
||||||
...(req.files?.photos || [])
|
|
||||||
]
|
|
||||||
return uniqueImages(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getProductImages(productId, fallbackImage) {
|
|
||||||
const [images] = await pool.query(
|
|
||||||
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
|
|
||||||
[productId]
|
|
||||||
)
|
|
||||||
|
|
||||||
const imagePaths = images.map((item) => item.image).filter(Boolean)
|
|
||||||
if (imagePaths.length) return imagePaths
|
|
||||||
return fallbackImage ? [fallbackImage] : []
|
|
||||||
}
|
|
||||||
|
|
||||||
async function attachProductImages(products) {
|
|
||||||
return Promise.all(
|
|
||||||
products.map(async (product) => ({
|
|
||||||
...product,
|
|
||||||
images: await getProductImages(product.id, product.image)
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findProductById(id) {
|
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) => {
|
||||||
@@ -183,24 +96,6 @@ app.get("/api/health", async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get("/api/debug/products/:id/images", async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const product = await findProductById(req.params.id)
|
|
||||||
if (!product) {
|
|
||||||
res.status(404).json({ message: "Produk tidak ditemukan." })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res.json({
|
|
||||||
product_id: Number(req.params.id),
|
|
||||||
main_image: product.image,
|
|
||||||
image_count: product.images.length,
|
|
||||||
images: product.images
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
next(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post("/api/auth/register", async (req, res, next) => {
|
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
|
||||||
@@ -221,7 +116,7 @@ app.post("/api/auth/register", async (req, res, next) => {
|
|||||||
[name, email.toLowerCase(), whatsapp, passwordHash]
|
[name, email.toLowerCase(), whatsapp, passwordHash]
|
||||||
)
|
)
|
||||||
|
|
||||||
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" }
|
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp }
|
||||||
res.status(201).json({ token: signUser(user), user: publicUser(user) })
|
res.status(201).json({ token: signUser(user), user: publicUser(user) })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === "ER_DUP_ENTRY") {
|
if (error.code === "ER_DUP_ENTRY") {
|
||||||
@@ -257,7 +152,7 @@ app.post("/api/auth/login", async (req, res, next) => {
|
|||||||
|
|
||||||
app.get("/api/auth/me", auth, async (req, res, next) => {
|
app.get("/api/auth/me", auth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id])
|
const [rows] = await pool.query("SELECT id, name, email, whatsapp FROM users WHERE id = ?", [req.user.id])
|
||||||
if (!rows[0]) {
|
if (!rows[0]) {
|
||||||
res.status(404).json({ message: "User tidak ditemukan." })
|
res.status(404).json({ message: "User tidak ditemukan." })
|
||||||
return
|
return
|
||||||
@@ -273,7 +168,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: await attachProductImages(rows) })
|
res.json({ products: rows })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error)
|
next(error)
|
||||||
}
|
}
|
||||||
@@ -285,7 +180,7 @@ app.get("/api/products/mine", auth, async (req, res, next) => {
|
|||||||
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC",
|
"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: await attachProductImages(rows) })
|
res.json({ products: rows })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error)
|
next(error)
|
||||||
}
|
}
|
||||||
@@ -304,46 +199,27 @@ app.get("/api/products/:id", async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post(
|
app.post("/api/products", auth, upload.single("image"), async (req, res, next) => {
|
||||||
"/api/products",
|
|
||||||
auth,
|
|
||||||
upload.fields([
|
|
||||||
{ name: "images", maxCount: maxImageCount },
|
|
||||||
{ name: "image", maxCount: maxImageCount },
|
|
||||||
{ name: "photos", maxCount: maxImageCount }
|
|
||||||
]),
|
|
||||||
async (req, res, next) => {
|
|
||||||
try {
|
try {
|
||||||
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
||||||
const uploadedImages = getUploadedImages(req)
|
|
||||||
|
|
||||||
console.log("Upload produk:", {
|
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.file) {
|
||||||
title,
|
res.status(400).json({ message: "Semua field produk dan foto wajib diisi." })
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainImagePath = `/uploads/${uploadedImages[0].filename}`
|
const imagePath = `/uploads/${req.file.filename}`
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
"INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"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]
|
[req.user.id, title, category, Number(price), condition, location, seller, whatsapp, imagePath, 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)
|
const product = await findProductById(result.insertId)
|
||||||
res.status(201).json({ product })
|
res.status(201).json({ product })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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 {
|
||||||
@@ -374,7 +250,10 @@ app.delete("/api/products/:id", auth, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
|
await 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) {
|
||||||
@@ -382,27 +261,9 @@ app.delete("/api/products/:id", auth, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const product = await findProductById(req.params.id)
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
res.status(404).json({ message: "Produk tidak ditemukan." })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
|
|
||||||
await deleteUploadedImages(product)
|
|
||||||
|
|
||||||
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
|
|
||||||
} catch (error) {
|
|
||||||
next(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use((error, req, res, next) => {
|
app.use((error, req, res, next) => {
|
||||||
if (error instanceof multer.MulterError) {
|
if (error instanceof multer.MulterError) {
|
||||||
res.status(400).json({ message: `Upload gagal. Ukuran setiap foto maksimal ${maxImageSizeMb} MB dan maksimal ${maxImageCount} foto.` })
|
res.status(400).json({ message: "Upload gagal. Ukuran foto maksimal 3MB." })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,20 +273,9 @@ app.use((error, req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
res.status(500).json({
|
res.status(500).json({ message: "Terjadi kesalahan pada server." })
|
||||||
message: "Terjadi kesalahan pada server.",
|
|
||||||
detail: process.env.NODE_ENV === "production" ? undefined : error.message
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ensureSchema()
|
app.listen(port, () => {
|
||||||
.then(() => {
|
|
||||||
app.listen(port, () => {
|
|
||||||
console.log(`SecondTech API berjalan di http://localhost:${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)
|
|
||||||
})
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 826 KiB After Width: | Height: | Size: 826 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 506 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 990 KiB |
@@ -39,461 +39,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="card p-4">
|
<aside class="card p-4">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<p class="text-xs font-bold uppercase tracking-wider text-slate-500">
|
<p class="text-xs font-bold uppercase tracking-wider text-slate-500">Menu User</p>
|
||||||
Menu User
|
|
||||||
</p>
|
|
||||||
<h2 class="mt-1 text-lg font-extrabold">Dashboard</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav class="grid gap-2">
|
<nav class="grid gap-2">
|
||||||
@@ -27,31 +10,18 @@
|
|||||||
<LayoutDashboard size="18" />
|
<LayoutDashboard size="18" />
|
||||||
Barang Saya
|
Barang Saya
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink class="side-link" to="/jual">
|
<RouterLink class="side-link" to="/jual">
|
||||||
<PlusCircle size="18" />
|
<PlusCircle size="18" />
|
||||||
Jual Barang
|
Jual Barang
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink class="side-link" to="/marketplace">
|
<RouterLink class="side-link" to="/marketplace">
|
||||||
<Store size="18" />
|
<Store size="18" />
|
||||||
Marketplace
|
Marketplace
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink class="side-link" to="/tersimpan">
|
<RouterLink class="side-link" to="/tersimpan">
|
||||||
<Heart size="18" />
|
<Heart size="18" />
|
||||||
Barang Tersimpan
|
Barang Tersimpan
|
||||||
</RouterLink>
|
</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">
|
<button class="side-link text-left" type="button" @click="logout">
|
||||||
<LogOut size="18" />
|
<LogOut size="18" />
|
||||||
Logout
|
Logout
|
||||||
@@ -61,66 +31,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from "vue"
|
import { useRouter } from "vue-router"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { Heart, LayoutDashboard, LogOut, PlusCircle, Store } from "lucide-vue-next"
|
||||||
import {
|
import { clearAuth } from "../utils"
|
||||||
Heart,
|
|
||||||
LayoutDashboard,
|
|
||||||
LogOut,
|
|
||||||
PlusCircle,
|
|
||||||
ShieldCheck,
|
|
||||||
Store
|
|
||||||
} from "lucide-vue-next"
|
|
||||||
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
|
||||||
|
|
||||||
const router = useRouter()
|
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() {
|
function logout() {
|
||||||
clearAuth()
|
clearAuth()
|
||||||
router.push("/login")
|
router.push("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(refreshCurrentUser)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.side-link {
|
.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;
|
@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 {
|
.router-link-active {
|
||||||
@apply bg-brand-50 text-brand-700;
|
@apply bg-brand-50 text-brand-700;
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-67
@@ -2,8 +2,12 @@
|
|||||||
<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
|
<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">
|
<div class="container-page flex h-16 items-center justify-between">
|
||||||
<RouterLink to="/" class="flex items-center gap-2">
|
<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">
|
<div class="flex items-center justify-center">
|
||||||
<Cpu size="20" />
|
<img
|
||||||
|
src="/round-logo-sija.png"
|
||||||
|
alt="SecondTech Logo"
|
||||||
|
class="h-10 w-10 object-contain"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -14,21 +18,13 @@
|
|||||||
<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>
|
|
||||||
</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">{{ currentUser.name }}</span>
|
<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>
|
<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">Login</RouterLink>
|
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">Login</RouterLink>
|
||||||
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink>
|
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink>
|
||||||
@@ -42,31 +38,17 @@
|
|||||||
|
|
||||||
<div v-if="open" class="border-t border-slate-200 bg-white md:hidden">
|
<div v-if="open" class="border-t border-slate-200 bg-white md:hidden">
|
||||||
<div class="container-page grid gap-2 py-4">
|
<div class="container-page grid gap-2 py-4">
|
||||||
<RouterLink class="mobile-link" to="/" @click="open = false">Home</RouterLink>
|
<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="/marketplace" @click="open=false">Marketplace</RouterLink>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 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>
|
<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">Login</RouterLink>
|
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open=false">Login</RouterLink>
|
||||||
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">Register</RouterLink>
|
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open=false">Register</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,18 +56,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from "vue"
|
import { ref, watch } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { Cpu, Menu, Moon, Sun } from "lucide-vue-next"
|
import { Cpu, Menu } from "lucide-vue-next"
|
||||||
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
import { clearAuth, getCurrentUser } 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(() => currentUser.value?.role === "superadmin")
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
@@ -94,51 +73,21 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function applyTheme() {
|
|
||||||
document.documentElement.classList.toggle("dark", darkMode.value)
|
|
||||||
localStorage.setItem("secondtech_theme", darkMode.value ? "dark" : "light")
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTheme() {
|
|
||||||
darkMode.value = !darkMode.value
|
|
||||||
applyTheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshCurrentUser() {
|
|
||||||
if (!getToken()) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fetchCurrentUser()
|
|
||||||
setAuth({ token: getToken(), user: data.user })
|
|
||||||
currentUser.value = getCurrentUser()
|
|
||||||
} catch {
|
|
||||||
clearAuth()
|
|
||||||
currentUser.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearAuth()
|
clearAuth()
|
||||||
currentUser.value = null
|
currentUser.value = null
|
||||||
open.value = false
|
open.value = false
|
||||||
router.push("/login")
|
router.push("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
applyTheme()
|
|
||||||
refreshCurrentUser()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.nav-link {
|
.nav-link {
|
||||||
@apply text-sm font-semibold text-slate-600 transition hover:text-brand-700;
|
@apply text-sm font-semibold text-slate-600 transition hover:text-brand-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.router-link-active {
|
.router-link-active {
|
||||||
@apply text-brand-700;
|
@apply text-brand-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-link {
|
.mobile-link {
|
||||||
@apply rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100;
|
@apply rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,15 +108,3 @@ 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" }
|
|
||||||
]
|
|
||||||
|
|||||||
+1
-14
@@ -8,8 +8,7 @@ import DashboardView from "../views/DashboardView.vue"
|
|||||||
import SavedView from "../views/SavedView.vue"
|
import SavedView from "../views/SavedView.vue"
|
||||||
import LoginView from "../views/LoginView.vue"
|
import LoginView from "../views/LoginView.vue"
|
||||||
import RegisterView from "../views/RegisterView.vue"
|
import RegisterView from "../views/RegisterView.vue"
|
||||||
import AdminProductsView from "../views/AdminProductsView.vue"
|
import { getToken } from "../utils"
|
||||||
import { getCurrentUser, getToken } from "../utils"
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/", name: "home", component: HomeView },
|
{ path: "/", name: "home", component: HomeView },
|
||||||
@@ -18,12 +17,6 @@ const routes = [
|
|||||||
{ path: "/jual", name: "sell-product", component: SellProductView, meta: { requiresAuth: true } },
|
{ path: "/jual", name: "sell-product", component: SellProductView, meta: { requiresAuth: true } },
|
||||||
{ path: "/dashboard", name: "dashboard", component: DashboardView, meta: { requiresAuth: true } },
|
{ path: "/dashboard", name: "dashboard", component: DashboardView, meta: { requiresAuth: true } },
|
||||||
{ path: "/tersimpan", name: "saved", component: SavedView, meta: { requiresAuth: true } },
|
{ path: "/tersimpan", name: "saved", component: SavedView, meta: { requiresAuth: true } },
|
||||||
{
|
|
||||||
path: "/admin/products",
|
|
||||||
name: "admin-products",
|
|
||||||
component: AdminProductsView,
|
|
||||||
meta: { requiresAuth: true, requiresSuperadmin: true }
|
|
||||||
},
|
|
||||||
{ path: "/login", name: "login", component: LoginView },
|
{ path: "/login", name: "login", component: LoginView },
|
||||||
{ path: "/register", name: "register", component: RegisterView }
|
{ path: "/register", name: "register", component: RegisterView }
|
||||||
]
|
]
|
||||||
@@ -37,12 +30,6 @@ router.beforeEach((to) => {
|
|||||||
if (to.meta.requiresAuth && !getToken()) {
|
if (to.meta.requiresAuth && !getToken()) {
|
||||||
return "/login"
|
return "/login"
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = getCurrentUser()
|
|
||||||
|
|
||||||
if (to.meta.requiresSuperadmin && user?.role !== "superadmin") {
|
|
||||||
return "/dashboard"
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
+1
-18
@@ -31,14 +31,7 @@ export function getCurrentUser() {
|
|||||||
|
|
||||||
export function setAuth({ token, user }) {
|
export function setAuth({ token, user }) {
|
||||||
localStorage.setItem("secondtech_token", token)
|
localStorage.setItem("secondtech_token", token)
|
||||||
|
setLocal("secondtech_user", user)
|
||||||
setLocal("secondtech_user", {
|
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
whatsapp: user.whatsapp,
|
|
||||||
role: user.role || "user"
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuth() {
|
export function clearAuth() {
|
||||||
@@ -86,10 +79,6 @@ export function loginUser(payload) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchCurrentUser() {
|
|
||||||
return requestJson("/api/auth/me")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchProducts() {
|
export function fetchProducts() {
|
||||||
return requestJson("/api/products")
|
return requestJson("/api/products")
|
||||||
}
|
}
|
||||||
@@ -122,12 +111,6 @@ export function deleteProductById(id) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteProductAsAdmin(id) {
|
|
||||||
return requestJson(`/api/admin/products/${id}`, {
|
|
||||||
method: "DELETE"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSavedProducts() {
|
export function getSavedProducts() {
|
||||||
return getLocal("secondtech_saved", [])
|
return getLocal("secondtech_saved", [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="container-page py-10">
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-extrabold">Admin Marketplace</h1>
|
|
||||||
<p class="mt-2 text-slate-600">Kelola semua produk yang tampil di marketplace.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="errorMessage" class="mb-5 rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
|
||||||
{{ errorMessage }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div v-if="loading" class="card p-10 text-center">
|
|
||||||
<h2 class="text-xl font-extrabold">Memuat produk...</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="products.length" class="grid gap-4">
|
|
||||||
<div
|
|
||||||
v-for="product in products"
|
|
||||||
:key="product.id"
|
|
||||||
class="card grid gap-4 p-4 md:grid-cols-[140px_1fr_auto] md:items-center"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="getProductImageUrl(product.image)"
|
|
||||||
:alt="product.title"
|
|
||||||
class="aspect-[4/3] w-full rounded-xl object-cover md:w-36"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span class="rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700">
|
|
||||||
{{ product.category }}
|
|
||||||
</span>
|
|
||||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
|
|
||||||
{{ product.status }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="mt-3 font-extrabold">{{ product.title }}</h3>
|
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
|
||||||
{{ product.seller }} • {{ product.location }} • {{ product.condition }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 font-extrabold text-brand-700">
|
|
||||||
{{ formatRupiah(product.price) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 md:flex-col">
|
|
||||||
<RouterLink :to="`/produk/${product.id}`" class="btn-secondary !px-3 !py-2">
|
|
||||||
Detail
|
|
||||||
</RouterLink>
|
|
||||||
<button
|
|
||||||
class="rounded-xl bg-red-50 px-3 py-2 text-sm font-bold text-red-700 hover:bg-red-100"
|
|
||||||
@click="deleteAsAdmin(product.id)"
|
|
||||||
>
|
|
||||||
Hapus
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="card p-10 text-center">
|
|
||||||
<h2 class="text-xl font-extrabold">Belum ada produk</h2>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, ref } from "vue"
|
|
||||||
import { API_BASE_URL, fetchProducts, formatRupiah, getProductImageUrl, getToken } from "../utils"
|
|
||||||
|
|
||||||
const products = ref([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const errorMessage = ref("")
|
|
||||||
|
|
||||||
async function loadProducts() {
|
|
||||||
loading.value = true
|
|
||||||
errorMessage.value = ""
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fetchProducts()
|
|
||||||
products.value = data.products
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = error.message
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAsAdmin(id) {
|
|
||||||
if (!confirm("Yakin ingin menghapus produk ini dari marketplace?")) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/api/admin/products/${id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${getToken()}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}))
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.message || "Gagal menghapus produk.")
|
|
||||||
}
|
|
||||||
|
|
||||||
products.value = products.value.filter((product) => product.id !== id)
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = error.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadProducts)
|
|
||||||
</script>
|
|
||||||
+9
-24
@@ -22,10 +22,11 @@
|
|||||||
:typingSpeed="75"
|
:typingSpeed="75"
|
||||||
:pauseDuration="1500"
|
:pauseDuration="1500"
|
||||||
:showCursor="false"
|
:showCursor="false"
|
||||||
:textColors="['var(--hero-title-color)']"
|
:textColors="['#0f1729']"
|
||||||
cursorCharacter="|"
|
cursorCharacter="|"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
@@ -35,7 +36,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 class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl" src="https://images.unsplash.com/photo-1516321318423-f06f85e504b3?q=80&w=1200&auto=format&fit=crop" alt="Tech marketplace" />
|
<img class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl" src="/kolase home.png" alt="Tech marketplace" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -50,12 +51,11 @@
|
|||||||
</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.name" class="card p-5 text-center transition hover:-translate-y-1 hover:shadow-md">
|
<div v-for="cat in categoryList" :key="cat" class="card p-5 text-center transition hover:-translate-y-1 hover:shadow-md">
|
||||||
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-700">
|
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-700">
|
||||||
<component :is="iconMap[cat.icon]" :size="22" />
|
<Cpu size="22" />
|
||||||
</div>
|
</div>
|
||||||
|
<p class="font-bold">{{ cat }}</p>
|
||||||
<p class="font-bold">{{ cat.name }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -78,30 +78,16 @@
|
|||||||
|
|
||||||
<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 { products, categories } from '../data/products';
|
||||||
import { getSavedProducts, setSavedProducts } from '../utils';
|
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 ShinyText from '../vuebits/ShinyText/ShinyText.vue';
|
||||||
import TextType from '../vuebits/TextType/TextType.vue';
|
import TextType from '../vuebits/TextType/TextType.vue';
|
||||||
|
|
||||||
const saved = ref(getSavedProducts());
|
const saved = ref(getSavedProducts());
|
||||||
const categoryList = categoryOptions
|
const categoryList = categories.filter((c) => c !== 'Semua').slice(0, 6);
|
||||||
.filter((category) => category.name !== 'Semua')
|
|
||||||
.slice(0, 6);
|
|
||||||
const latestProducts = computed(() => products.slice(0, 4));
|
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)) {
|
||||||
@@ -112,4 +98,3 @@ function toggleSave(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">Selamat Datang Kembali!</h1>
|
<h1 class="text-2xl font-extrabold">Login</h1>
|
||||||
<p class="mt-2 text-sm text-slate-600">Masuk dan lanjutkan jualanmu.</p>
|
<p class="mt-2 text-sm text-slate-600">Masuk untuk mengelola barang jualanmu.</p>
|
||||||
|
|
||||||
<form class="mt-6 grid gap-4" @submit.prevent="login">
|
<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,23 +9,8 @@
|
|||||||
</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="grid gap-4 sm:grid-cols-[88px_1fr]">
|
<div class="card overflow-hidden">
|
||||||
<div class="order-2 flex gap-3 overflow-x-auto sm:order-1 sm:grid sm:max-h-[520px] sm:overflow-y-auto">
|
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
|
||||||
<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>
|
||||||
@@ -60,7 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap gap-3">
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
<a :href="waLink" target="_blank" class="btn-primary">Hubungi via WhatsApp</a>
|
<a :href="waLink" target="_blank" class="btn-WA">Hubungi via WhatsApp</a>
|
||||||
<button class="btn-secondary" @click="toggleSave">
|
<button class="btn-secondary" @click="toggleSave">
|
||||||
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
|
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
|
||||||
</button>
|
</button>
|
||||||
@@ -76,7 +61,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from "vue"
|
import { computed, onMounted, ref } 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"
|
||||||
@@ -85,23 +70,6 @@ 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))
|
||||||
@@ -127,8 +95,7 @@ async function loadProduct() {
|
|||||||
const data = await fetchProduct(id)
|
const data = await fetchProduct(id)
|
||||||
product.value = data.product
|
product.value = data.product
|
||||||
} catch {
|
} catch {
|
||||||
const fallbackProduct = defaultProducts.find((item) => Number(item.id) === id) || null
|
product.value = 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 beberapa foto asli dari perangkatmu.</p>
|
<p class="mt-2 text-slate-600">Posting barang ke marketplace dengan foto asli dari perangkatmu.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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" required>
|
<select v-model="form.category" class="input mt-2">
|
||||||
<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,32 +58,9 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="label">Foto Barang</label>
|
<label class="label">Foto Barang</label>
|
||||||
<input
|
<input type="file" accept="image/*" class="input mt-2" required @change="handleImageChange" />
|
||||||
ref="fileInput"
|
<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" />
|
||||||
type="file"
|
<p class="mt-2 text-xs text-slate-500">Upload JPG, PNG, atau WebP. Maksimal 3MB.</p>
|
||||||
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>
|
||||||
@@ -99,10 +76,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>- Upload beberapa foto dari sisi yang berbeda.</li>
|
<li>• Pakai foto yang terang dan jelas.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,10 +94,6 @@ 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: "",
|
||||||
@@ -132,8 +105,8 @@ const form = reactive({
|
|||||||
whatsapp: "",
|
whatsapp: "",
|
||||||
description: ""
|
description: ""
|
||||||
})
|
})
|
||||||
const selectedImages = ref([])
|
const selectedImage = ref(null)
|
||||||
const previewUrls = ref([])
|
const previewUrl = ref("")
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref("")
|
const errorMessage = ref("")
|
||||||
|
|
||||||
@@ -143,55 +116,15 @@ 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 incomingFiles = Array.from(event.target.files || [])
|
const file = event.target.files?.[0]
|
||||||
const oversizedFile = incomingFiles.find((file) => file.size > MAX_IMAGE_SIZE)
|
selectedImage.value = file || null
|
||||||
|
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 (!selectedImages.value.length) {
|
if (!selectedImage.value) {
|
||||||
errorMessage.value = "Minimal 1 foto barang wajib diupload."
|
errorMessage.value = "Foto barang wajib diupload."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,9 +135,7 @@ async function submitProduct() {
|
|||||||
Object.entries(form).forEach(([key, value]) => {
|
Object.entries(form).forEach(([key, value]) => {
|
||||||
payload.append(key, value)
|
payload.append(key, value)
|
||||||
})
|
})
|
||||||
selectedImages.value.forEach((image) => {
|
payload.append("image", selectedImage.value)
|
||||||
payload.append("images", image)
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createProduct(payload)
|
await createProduct(payload)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/** @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}"
|
||||||
@@ -24,4 +23,3 @@ export default {
|
|||||||
},
|
},
|
||||||
plugins: []
|
plugins: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user