3 Commits

Author SHA1 Message Date
Grey 0f66595775 Merge branch 'fitur-navbar' into dev 2026-05-15 18:09:00 +07:00
Grey d855f2e75a style:mengubah icon home view 2026-05-15 18:01:55 +07:00
Grey e359365139 feat:menambahkan fitur superadmin 2026-05-15 00:25:37 +07:00
12 changed files with 438 additions and 47 deletions
+1
View File
@@ -10,6 +10,7 @@ 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
); );
+45 -3
View File
@@ -58,7 +58,8 @@ 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"
} }
} }
@@ -116,7 +117,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 } const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" }
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") {
@@ -152,7 +153,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 FROM users WHERE id = ?", [req.user.id]) const [rows] = await pool.query("SELECT id, name, email, whatsapp, role 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
@@ -261,6 +262,28 @@ 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])
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) => { 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 foto maksimal 3MB." })
@@ -279,3 +302,22 @@ app.use((error, req, res, next) => {
app.listen(port, () => { app.listen(port, () => {
console.log(`SecondTech API berjalan di http://localhost:${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)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 KiB

+78 -4
View File
@@ -1,8 +1,25 @@
<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">Menu User</p> <p class="text-xs font-bold uppercase tracking-wider text-slate-500">
Menu User
</p>
<h2 class="mt-1 text-lg font-extrabold">Dashboard</h2> <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">
@@ -10,18 +27,31 @@
<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
@@ -31,22 +61,66 @@
</template> </template>
<script setup> <script setup>
import { useRouter } from "vue-router" import { computed, onMounted, ref, watch } from "vue"
import { Heart, LayoutDashboard, LogOut, PlusCircle, Store } from "lucide-vue-next" import { useRoute, useRouter } from "vue-router"
import { clearAuth } from "../utils" import {
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;
} }
+83 -9
View File
@@ -18,16 +18,41 @@
<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">
<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">
<button class="btn-secondary !px-4 !py-2" @click="logout">Logout</button> {{ 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>
<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">
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink> Login
</RouterLink>
<RouterLink to="/register" class="btn-primary !px-4 !py-2">
Register
</RouterLink>
</template> </template>
</div> </div>
@@ -43,12 +68,37 @@
<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>
<div v-if="currentUser" class="mt-2 grid gap-2"> <div v-if="currentUser" class="mt-2 grid gap-2">
<button class="btn-secondary !py-2 text-center" @click="logout">Logout</button> <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> </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"> <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">
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open=false">Register</RouterLink> Login
</RouterLink>
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">
Register
</RouterLink>
</div> </div>
</div> </div>
</div> </div>
@@ -56,16 +106,20 @@
</template> </template>
<script setup> <script setup>
import { 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 } from "lucide-vue-next"
import { clearAuth, getCurrentUser } 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 isSuperadmin = computed(() => {
return currentUser.value?.role === "superadmin"
})
watch( watch(
() => route.fullPath, () => route.fullPath,
() => { () => {
@@ -73,21 +127,41 @@ watch(
} }
) )
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(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;
} }
+36 -9
View File
@@ -98,13 +98,40 @@ export const products = [
] ]
export const categories = [ export const categories = [
"Semua", {
"Laptop", name: "Semua",
"PC", icon: "Boxes"
"Monitor", },
"Keyboard", {
"Router", name: "Laptop",
"Switch", icon: "Laptop"
"Server", },
"Access Point" {
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"
}
] ]
+14 -1
View File
@@ -8,7 +8,8 @@ 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 { getToken } from "../utils" import AdminProductsView from "../views/AdminProductsView.vue"
import { getCurrentUser, getToken } from "../utils"
const routes = [ const routes = [
{ path: "/", name: "home", component: HomeView }, { path: "/", name: "home", component: HomeView },
@@ -17,6 +18,12 @@ 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 }
] ]
@@ -30,6 +37,12 @@ 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
+19 -1
View File
@@ -31,7 +31,14 @@ 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() {
@@ -56,6 +63,7 @@ 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) {
@@ -79,6 +87,10 @@ 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")
} }
@@ -111,6 +123,12 @@ 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", [])
} }
+113
View File
@@ -0,0 +1,113 @@
<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>
+37 -8
View File
@@ -6,10 +6,10 @@
Marketplace Barang Bekas Teknologi Marketplace Barang Bekas Teknologi
</span> </span>
<h1 class="mt-6 text-4xl font-extrabold tracking-tight text-slate-950 sm:text-5xl lg:text-6xl"> <h1 class="mt-6 text-4xl font-extrabold tracking-tight text-slate-950 sm:text-5xl lg:text-6xl">
Jual beli perangkat Teknologi bekas. Jual beli perangkat IT bekas jadi lebih mudah.
</h1> </h1>
<p class="mt-6 max-w-xl text-lg leading-8 text-slate-600"> <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. Temukan laptop bekas, PC, monitor, keyboard, router, switch, access point, dan server bekas untuk kebutuhan belajar SIJA/TKJ.
</p> </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>
@@ -38,11 +38,16 @@
</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>
@@ -71,14 +76,38 @@
<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, categories } from "../data/products"
const saved = ref(getSavedProducts()) const saved = ref(getSavedProducts())
const categoryList = categories.filter((c) => c !== "Semua").slice(0, 6) const categoryList = categories
.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)) {
+2 -2
View File
@@ -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">
+2 -2
View File
@@ -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 anda ke marketplace dengan foto produk asli.</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]">
@@ -65,7 +65,7 @@
<div> <div>
<label class="label">Deskripsi</label> <label class="label">Deskripsi</label>
<textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Deskripsikan kondisi barang anda..." required></textarea> <textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Jelaskan kondisi barang..." required></textarea>
</div> </div>
<button class="btn-primary w-full" :disabled="loading"> <button class="btn-primary w-full" :disabled="loading">