first commit
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<Navbar />
|
||||
<main>
|
||||
<RouterView />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Navbar from "./components/Navbar.vue"
|
||||
import Footer from "./components/Footer.vue"
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 font-sans;
|
||||
}
|
||||
|
||||
.container-page {
|
||||
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-2xl border border-slate-200 bg-white shadow-sm;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply text-sm font-semibold text-slate-700;
|
||||
}
|
||||
|
||||
.btn-wa {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<aside class="card p-4">
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-bold uppercase tracking-wider text-slate-500">Menu User</p>
|
||||
<h2 class="mt-1 text-lg font-extrabold">Dashboard</h2>
|
||||
</div>
|
||||
|
||||
<nav class="grid gap-2">
|
||||
<RouterLink class="side-link" to="/dashboard">
|
||||
<LayoutDashboard size="18" />
|
||||
Barang Saya
|
||||
</RouterLink>
|
||||
<RouterLink class="side-link" to="/jual">
|
||||
<PlusCircle size="18" />
|
||||
Jual Barang
|
||||
</RouterLink>
|
||||
<RouterLink class="side-link" to="/marketplace">
|
||||
<Store size="18" />
|
||||
Marketplace
|
||||
</RouterLink>
|
||||
<RouterLink class="side-link" to="/tersimpan">
|
||||
<Heart size="18" />
|
||||
Barang Tersimpan
|
||||
</RouterLink>
|
||||
<button class="side-link text-left" type="button" @click="logout">
|
||||
<LogOut size="18" />
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from "vue-router"
|
||||
import { Heart, LayoutDashboard, LogOut, PlusCircle, Store } from "lucide-vue-next"
|
||||
import { clearAuth } from "../utils"
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function logout() {
|
||||
clearAuth()
|
||||
router.push("/login")
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.side-link {
|
||||
@apply flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-100;
|
||||
}
|
||||
.router-link-active {
|
||||
@apply bg-brand-50 text-brand-700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<footer class="border-t border-slate-200 bg-slate-950 text-white">
|
||||
<div class="container-page py-10">
|
||||
<div class="grid gap-8 md:grid-cols-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-extrabold">SecondTech Market</h2>
|
||||
<p class="mt-3 text-sm leading-6 text-slate-300">
|
||||
Marketplace barang bekas teknologi untuk laptop, PC, monitor, keyboard, perangkat jaringan, dan server.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold">Navigasi</h3>
|
||||
<div class="mt-3 grid gap-2 text-sm text-slate-300">
|
||||
<RouterLink to="/marketplace">Marketplace</RouterLink>
|
||||
<RouterLink to="/jual">Jual Barang</RouterLink>
|
||||
<RouterLink to="/dashboard">Dashboard</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold">Catatan Project</h3>
|
||||
<p class="mt-3 text-sm leading-6 text-slate-300">
|
||||
Versi ini memakai backend Express, database MySQL, dan upload foto produk dari file perangkat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-10 text-xs text-slate-400">© 2026 SecondTech Market. Dibuat untuk project web.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||
<div class="container-page flex h-16 items-center justify-between">
|
||||
<RouterLink to="/" class="flex items-center gap-2">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-600 text-white">
|
||||
<Cpu size="20" />
|
||||
</div>
|
||||
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden items-center gap-6 md:flex">
|
||||
<RouterLink class="nav-link" to="/">Home</RouterLink>
|
||||
<RouterLink class="nav-link" to="/marketplace">Marketplace</RouterLink>
|
||||
<RouterLink class="nav-link" to="/jual">Jual Barang</RouterLink>
|
||||
<RouterLink class="nav-link" to="/dashboard">Dashboard</RouterLink>
|
||||
<RouterLink class="nav-link" to="/tersimpan">Tersimpan</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="hidden items-center gap-3 md:flex">
|
||||
<template v-if="currentUser">
|
||||
<span class="text-sm font-bold text-slate-700">{{ currentUser.name }}</span>
|
||||
<button class="btn-secondary !px-4 !py-2" @click="logout">Logout</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">Login</RouterLink>
|
||||
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button class="rounded-xl border border-slate-200 p-2 md:hidden" @click="open = !open">
|
||||
<Menu />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="open" class="border-t border-slate-200 bg-white md:hidden">
|
||||
<div class="container-page grid gap-2 py-4">
|
||||
<RouterLink class="mobile-link" to="/" @click="open=false">Home</RouterLink>
|
||||
<RouterLink class="mobile-link" to="/marketplace" @click="open=false">Marketplace</RouterLink>
|
||||
<RouterLink class="mobile-link" to="/jual" @click="open=false">Jual Barang</RouterLink>
|
||||
<RouterLink class="mobile-link" to="/dashboard" @click="open=false">Dashboard</RouterLink>
|
||||
<RouterLink class="mobile-link" to="/tersimpan" @click="open=false">Tersimpan</RouterLink>
|
||||
<div v-if="currentUser" class="mt-2 grid gap-2">
|
||||
<button class="btn-secondary !py-2 text-center" @click="logout">Logout</button>
|
||||
</div>
|
||||
<div v-else class="mt-2 grid grid-cols-2 gap-2">
|
||||
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open=false">Login</RouterLink>
|
||||
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open=false">Register</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { Cpu, Menu } from "lucide-vue-next"
|
||||
import { clearAuth, getCurrentUser } from "../utils"
|
||||
|
||||
const open = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const currentUser = ref(getCurrentUser())
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
currentUser.value = getCurrentUser()
|
||||
}
|
||||
)
|
||||
|
||||
function logout() {
|
||||
clearAuth()
|
||||
currentUser.value = null
|
||||
open.value = false
|
||||
router.push("/login")
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
@apply text-sm font-semibold text-slate-600 transition hover:text-brand-700;
|
||||
}
|
||||
.router-link-active {
|
||||
@apply text-brand-700;
|
||||
}
|
||||
.mobile-link {
|
||||
@apply rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="card group overflow-hidden">
|
||||
<div class="relative aspect-[4/3] overflow-hidden bg-slate-100">
|
||||
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="h-full w-full object-cover transition duration-500 group-hover:scale-105" />
|
||||
<span class="absolute left-3 top-3 rounded-full bg-white/90 px-3 py-1 text-xs font-bold text-slate-700">
|
||||
{{ product.category }}
|
||||
</span>
|
||||
<button
|
||||
class="absolute right-3 top-3 rounded-full bg-white/90 p-2 text-slate-700 transition hover:bg-brand-600 hover:text-white"
|
||||
@click.prevent="$emit('toggle-save', product.id)"
|
||||
>
|
||||
<Heart :fill="saved ? 'currentColor' : 'none'" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
|
||||
{{ product.condition }}
|
||||
</span>
|
||||
<span class="text-xs font-semibold text-slate-500">{{ product.location }}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="line-clamp-2 min-h-[48px] text-base font-bold text-slate-900">
|
||||
{{ product.title }}
|
||||
</h3>
|
||||
|
||||
<p class="mt-2 text-lg font-extrabold text-brand-700">
|
||||
{{ formatRupiah(product.price) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<RouterLink :to="`/produk/${product.id}`" class="btn-secondary flex-1 !px-3 !py-2 text-center">
|
||||
Detail
|
||||
</RouterLink>
|
||||
<a :href="waLink" target="_blank" class="btn-primary flex-1 !px-3 !py-2 text-center">
|
||||
Massage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue"
|
||||
import { Heart } from "lucide-vue-next"
|
||||
import { formatRupiah, getProductImageUrl } from "../utils"
|
||||
|
||||
const props = defineProps({
|
||||
product: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
saved: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(["toggle-save"])
|
||||
|
||||
const waLink = computed(() => {
|
||||
const text = encodeURIComponent(`Halo, saya tertarik dengan ${props.product.title} di SecondTech Market.`)
|
||||
return `https://wa.me/${props.product.whatsapp}?text=${text}`
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
export const products = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Laptop Lenovo ThinkPad T480",
|
||||
category: "Laptop",
|
||||
price: 3200000,
|
||||
condition: "Bekas Normal",
|
||||
location: "Yogyakarta",
|
||||
seller: "Raka SIJA",
|
||||
whatsapp: "6281234567890",
|
||||
image: "https://images.unsplash.com/photo-1496181133206-80ce9b88a853?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Laptop bekas cocok untuk belajar coding, jaringan, virtual machine ringan, dan kebutuhan sekolah. RAM 8GB, SSD 256GB, keyboard normal."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "PC Rakitan i5 Gen 8",
|
||||
category: "PC",
|
||||
price: 4100000,
|
||||
condition: "Bekas Normal",
|
||||
location: "Bantul",
|
||||
seller: "Dimas Tech",
|
||||
whatsapp: "628111222333",
|
||||
image: "https://images.unsplash.com/photo-1587202372775-e229f172b9d7?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "PC rakitan untuk lab jaringan, desain ringan, dan multitasking. Intel Core i5, RAM 16GB, SSD 512GB."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Monitor LG 24 Inch IPS",
|
||||
category: "Monitor",
|
||||
price: 1150000,
|
||||
condition: "Bekas Mulus",
|
||||
location: "Sleman",
|
||||
seller: "Adit Hardware",
|
||||
whatsapp: "628555666777",
|
||||
image: "https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Monitor IPS 24 inch, warna masih bagus, cocok untuk coding dan editing. Include kabel power dan HDMI."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Keyboard Mechanical Keychron K2",
|
||||
category: "Keyboard",
|
||||
price: 850000,
|
||||
condition: "Bekas Normal",
|
||||
location: "Solo",
|
||||
seller: "Naufal Keys",
|
||||
whatsapp: "628333444555",
|
||||
image: "https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Keyboard mechanical wireless, switch brown, cocok untuk coding. Kondisi normal dan keycap lengkap."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "MikroTik RB941 hAP Lite",
|
||||
category: "Router",
|
||||
price: 180000,
|
||||
condition: "Bekas Normal",
|
||||
location: "Kulon Progo",
|
||||
seller: "Lab Network",
|
||||
whatsapp: "628777888999",
|
||||
image: "https://images.unsplash.com/photo-1606904825846-647eb07f5be2?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Router MikroTik untuk belajar routing, firewall, DHCP, hotspot, dan konfigurasi dasar jaringan."
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Switch TP-Link 8 Port Gigabit",
|
||||
category: "Switch",
|
||||
price: 250000,
|
||||
condition: "Bekas Normal",
|
||||
location: "Magelang",
|
||||
seller: "Fajar Net",
|
||||
whatsapp: "628999111222",
|
||||
image: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Switch 8 port gigabit, cocok untuk lab kecil, warnet mini, dan praktik jaringan lokal."
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Server Dell PowerEdge R620",
|
||||
category: "Server",
|
||||
price: 6500000,
|
||||
condition: "Bekas Server Room",
|
||||
location: "Jakarta",
|
||||
seller: "Server Bekas ID",
|
||||
whatsapp: "628222333444",
|
||||
image: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Server rackmount bekas data center, cocok untuk belajar virtualization, Proxmox, Docker, dan homelab."
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "Access Point TP-Link EAP225",
|
||||
category: "Access Point",
|
||||
price: 620000,
|
||||
condition: "Bekas Mulus",
|
||||
location: "Semarang",
|
||||
seller: "WiFi Store",
|
||||
whatsapp: "6281212121212",
|
||||
image: "https://images.unsplash.com/photo-1544197150-b99a580bb7a8?q=80&w=1200&auto=format&fit=crop",
|
||||
description: "Access point ceiling untuk hotspot sekolah, kantor, dan lab jaringan. Kondisi normal."
|
||||
}
|
||||
]
|
||||
|
||||
export const categories = [
|
||||
"Semua",
|
||||
"Laptop",
|
||||
"PC",
|
||||
"Monitor",
|
||||
"Keyboard",
|
||||
"Router",
|
||||
"Switch",
|
||||
"Server",
|
||||
"Access Point"
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from "vue"
|
||||
import App from "./App.vue"
|
||||
import router from "./router"
|
||||
import "./assets/main.css"
|
||||
|
||||
createApp(App).use(router).mount("#app")
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createRouter, createWebHistory } from "vue-router"
|
||||
|
||||
import HomeView from "../views/HomeView.vue"
|
||||
import MarketplaceView from "../views/MarketplaceView.vue"
|
||||
import ProductDetailView from "../views/ProductDetailView.vue"
|
||||
import SellProductView from "../views/SellProductView.vue"
|
||||
import DashboardView from "../views/DashboardView.vue"
|
||||
import SavedView from "../views/SavedView.vue"
|
||||
import LoginView from "../views/LoginView.vue"
|
||||
import RegisterView from "../views/RegisterView.vue"
|
||||
import { getToken } from "../utils"
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: HomeView },
|
||||
{ path: "/marketplace", name: "marketplace", component: MarketplaceView },
|
||||
{ path: "/produk/:id", name: "product-detail", component: ProductDetailView },
|
||||
{ path: "/jual", name: "sell-product", component: SellProductView, meta: { requiresAuth: true } },
|
||||
{ path: "/dashboard", name: "dashboard", component: DashboardView, meta: { requiresAuth: true } },
|
||||
{ path: "/tersimpan", name: "saved", component: SavedView, meta: { requiresAuth: true } },
|
||||
{ path: "/login", name: "login", component: LoginView },
|
||||
{ path: "/register", name: "register", component: RegisterView }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.requiresAuth && !getToken()) {
|
||||
return "/login"
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000"
|
||||
|
||||
export function formatRupiah(value) {
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function getLocal(key, fallback) {
|
||||
try {
|
||||
const value = localStorage.getItem(key)
|
||||
return value ? JSON.parse(value) : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocal(key, value) {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem("secondtech_token")
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return getLocal("secondtech_user", null)
|
||||
}
|
||||
|
||||
export function setAuth({ token, user }) {
|
||||
localStorage.setItem("secondtech_token", token)
|
||||
setLocal("secondtech_user", user)
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem("secondtech_token")
|
||||
localStorage.removeItem("secondtech_user")
|
||||
}
|
||||
|
||||
export function getProductImageUrl(image) {
|
||||
if (!image) return ""
|
||||
if (image.startsWith("http://") || image.startsWith("https://") || image.startsWith("data:")) return image
|
||||
return `${API_BASE_URL}${image}`
|
||||
}
|
||||
|
||||
async function requestJson(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {})
|
||||
const token = getToken()
|
||||
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`)
|
||||
if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json")
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Request gagal.")
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export function registerUser(payload) {
|
||||
return requestJson("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function loginUser(payload) {
|
||||
return requestJson("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchProducts() {
|
||||
return requestJson("/api/products")
|
||||
}
|
||||
|
||||
export function fetchProduct(id) {
|
||||
return requestJson(`/api/products/${id}`)
|
||||
}
|
||||
|
||||
export function fetchMyProducts() {
|
||||
return requestJson("/api/products/mine")
|
||||
}
|
||||
|
||||
export function createProduct(formData) {
|
||||
return requestJson("/api/products", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProductStatus(id, status) {
|
||||
return requestJson(`/api/products/${id}/status`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status })
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProductById(id) {
|
||||
return requestJson(`/api/products/${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
}
|
||||
|
||||
export function getSavedProducts() {
|
||||
return getLocal("secondtech_saved", [])
|
||||
}
|
||||
|
||||
export function setSavedProducts(ids) {
|
||||
setLocal("secondtech_saved", ids)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<section class="container-page py-10">
|
||||
<div class="grid gap-8 lg:grid-cols-[280px_1fr]">
|
||||
<DashboardSidebar />
|
||||
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-extrabold">Barang Saya</h1>
|
||||
<p class="mt-2 text-slate-600">Kelola barang yang kamu posting ke database.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div class="card p-5">
|
||||
<p class="text-sm font-semibold text-slate-500">Total Barang</p>
|
||||
<h2 class="mt-2 text-3xl font-extrabold">{{ userProducts.length }}</h2>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="text-sm font-semibold text-slate-500">Tersedia</p>
|
||||
<h2 class="mt-2 text-3xl font-extrabold">{{ availableCount }}</h2>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="text-sm font-semibold text-slate-500">Terjual</p>
|
||||
<h2 class="mt-2 text-3xl font-extrabold">{{ soldCount }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="text-xl font-extrabold">Management Barang</h2>
|
||||
<RouterLink to="/jual" class="btn-primary !px-4 !py-2">+ Tambah Barang</RouterLink>
|
||||
</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 barang...</h2>
|
||||
</div>
|
||||
|
||||
<div v-else-if="userProducts.length" class="grid gap-4">
|
||||
<div v-for="product in userProducts" :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 || "Tersedia" }}</span>
|
||||
</div>
|
||||
<h3 class="mt-3 font-extrabold">{{ product.title }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ 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="btn-secondary !px-3 !py-2" @click="markSold(product.id)">Tandai Terjual</button>
|
||||
<button class="rounded-xl bg-red-50 px-3 py-2 text-sm font-bold text-red-700 hover:bg-red-100" @click="deleteProduct(product.id)">Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="card p-10 text-center">
|
||||
<h2 class="text-xl font-extrabold">Belum ada barang</h2>
|
||||
<p class="mt-2 text-slate-600">Coba posting barang bekas teknologi pertamamu.</p>
|
||||
<RouterLink to="/jual" class="btn-primary mt-5">Jual Barang</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import DashboardSidebar from "../components/DashboardSidebar.vue"
|
||||
import { deleteProductById, fetchMyProducts, formatRupiah, getProductImageUrl, updateProductStatus } from "../utils"
|
||||
|
||||
const userProducts = ref([])
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref("")
|
||||
|
||||
const availableCount = computed(() => userProducts.value.filter((p) => (p.status || "Tersedia") === "Tersedia").length)
|
||||
const soldCount = computed(() => userProducts.value.filter((p) => p.status === "Terjual").length)
|
||||
|
||||
async function loadProducts() {
|
||||
loading.value = true
|
||||
errorMessage.value = ""
|
||||
|
||||
try {
|
||||
const data = await fetchMyProducts()
|
||||
userProducts.value = data.products
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markSold(id) {
|
||||
try {
|
||||
const data = await updateProductStatus(id, "Terjual")
|
||||
userProducts.value = userProducts.value.map((product) => {
|
||||
if (product.id === id) return data.product
|
||||
return product
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProduct(id) {
|
||||
if (!confirm("Yakin ingin menghapus barang ini?")) return
|
||||
|
||||
try {
|
||||
await deleteProductById(id)
|
||||
userProducts.value = userProducts.value.filter((product) => product.id !== id)
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProducts)
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<section class="bg-gradient-to-br from-brand-50 via-white to-slate-100">
|
||||
<div class="container-page grid min-h-[560px] items-center gap-10 py-16 lg:grid-cols-2">
|
||||
<div>
|
||||
<span class="rounded-full bg-brand-100 px-4 py-2 text-sm font-bold text-brand-700">
|
||||
Marketplace Barang Bekas Teknologi
|
||||
</span>
|
||||
<h1 class="mt-6 text-4xl font-extrabold tracking-tight text-slate-950 sm:text-5xl lg:text-6xl">
|
||||
Jual beli perangkat IT bekas jadi lebih mudah.
|
||||
</h1>
|
||||
<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 kebutuhan belajar SIJA/TKJ.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<RouterLink to="/jual" class="btn-primary">Jual Barang Sekarang</RouterLink>
|
||||
<RouterLink to="/marketplace" class="btn-secondary">Lihat Marketplace</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-4 rounded-[2rem] bg-brand-100 blur-2xl"></div>
|
||||
<img
|
||||
class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl"
|
||||
src="https://images.unsplash.com/photo-1516321318423-f06f85e504b3?q=80&w=1200&auto=format&fit=crop"
|
||||
alt="Tech marketplace"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container-page py-14">
|
||||
<div class="mb-8 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-extrabold">Kategori Populer</h2>
|
||||
<p class="mt-2 text-slate-600">Fokus ke perangkat teknologi dan jaringan.</p>
|
||||
</div>
|
||||
<RouterLink to="/marketplace" class="text-sm font-bold text-brand-700">Lihat semua</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<div v-for="cat in categoryList" :key="cat" 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">
|
||||
<Cpu size="22" />
|
||||
</div>
|
||||
<p class="font-bold">{{ cat }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white py-14">
|
||||
<div class="container-page">
|
||||
<div class="mb-8 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-extrabold">Produk Terbaru</h2>
|
||||
<p class="mt-2 text-slate-600">Contoh barang bekas teknologi yang tersedia.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<ProductCard
|
||||
v-for="product in latestProducts"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
:saved="saved.includes(product.id)"
|
||||
@toggle-save="toggleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue"
|
||||
import { Cpu } from "lucide-vue-next"
|
||||
import ProductCard from "../components/ProductCard.vue"
|
||||
import { products, categories } from "../data/products"
|
||||
import { getSavedProducts, setSavedProducts } from "../utils"
|
||||
|
||||
const saved = ref(getSavedProducts())
|
||||
const categoryList = categories.filter((c) => c !== "Semua").slice(0, 6)
|
||||
const latestProducts = computed(() => products.slice(0, 4))
|
||||
|
||||
function toggleSave(id) {
|
||||
if (saved.value.includes(id)) {
|
||||
saved.value = saved.value.filter((item) => item !== id)
|
||||
} else {
|
||||
saved.value.push(id)
|
||||
}
|
||||
setSavedProducts(saved.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<section class="container-page grid min-h-[70vh] place-items-center py-10">
|
||||
<div class="card w-full max-w-md p-6">
|
||||
<h1 class="text-2xl font-extrabold">Login</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">Masuk untuk mengelola barang jualanmu.</p>
|
||||
|
||||
<form class="mt-6 grid gap-4" @submit.prevent="login">
|
||||
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<div>
|
||||
<label class="label">Email</label>
|
||||
<input v-model="email" type="email" class="input mt-2" placeholder="nama@email.com" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Password</label>
|
||||
<input v-model="password" type="password" class="input mt-2" placeholder="••••••••" required />
|
||||
</div>
|
||||
<button class="btn-primary w-full" :disabled="loading">
|
||||
{{ loading ? "Memproses..." : "Masuk" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-5 text-center text-sm text-slate-600">
|
||||
Belum punya akun?
|
||||
<RouterLink to="/register" class="font-bold text-brand-700">Register</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { loginUser, setAuth } from "../utils"
|
||||
|
||||
const router = useRouter()
|
||||
const email = ref("")
|
||||
const password = ref("")
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref("")
|
||||
|
||||
async function login() {
|
||||
loading.value = true
|
||||
errorMessage.value = ""
|
||||
|
||||
try {
|
||||
const data = await loginUser({
|
||||
email: email.value,
|
||||
password: password.value
|
||||
})
|
||||
setAuth(data)
|
||||
router.push("/dashboard")
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<section class="container-page py-10">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-extrabold">Marketplace</h1>
|
||||
<p class="mt-2 text-slate-600">Cari barang bekas teknologi sesuai kebutuhanmu.</p>
|
||||
</div>
|
||||
|
||||
<div class="card mb-8 grid gap-4 p-4 lg:grid-cols-4">
|
||||
<input v-model="search" class="input lg:col-span-2" placeholder="Cari laptop, router, server..." />
|
||||
|
||||
<select v-model="selectedCategory" class="input">
|
||||
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
|
||||
</select>
|
||||
|
||||
<select v-model="selectedCondition" class="input">
|
||||
<option value="Semua">Semua Kondisi</option>
|
||||
<option value="Bekas Normal">Bekas Normal</option>
|
||||
<option value="Bekas Mulus">Bekas Mulus</option>
|
||||
<option value="Bekas Server Room">Bekas Server Room</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<p class="text-sm font-semibold text-slate-600">
|
||||
{{ loading ? "Memuat produk..." : `Menampilkan ${filteredProducts.length} produk` }}
|
||||
</p>
|
||||
<RouterLink to="/jual" class="btn-primary !px-4 !py-2">+ Jual Barang</RouterLink>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="mb-5 rounded-xl bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-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="filteredProducts.length" class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<ProductCard
|
||||
v-for="product in filteredProducts"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
:saved="saved.includes(product.id)"
|
||||
@toggle-save="toggleSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="card p-10 text-center">
|
||||
<h2 class="text-xl font-extrabold">Produk tidak ditemukan</h2>
|
||||
<p class="mt-2 text-slate-600">Coba ganti keyword atau filter kategori.</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import ProductCard from "../components/ProductCard.vue"
|
||||
import { products as defaultProducts, categories } from "../data/products"
|
||||
import { fetchProducts, getSavedProducts, setSavedProducts } from "../utils"
|
||||
|
||||
const search = ref("")
|
||||
const selectedCategory = ref("Semua")
|
||||
const selectedCondition = ref("Semua")
|
||||
const saved = ref(getSavedProducts())
|
||||
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) {
|
||||
products.value = defaultProducts
|
||||
errorMessage.value = "Backend belum bisa dihubungi, jadi sementara menampilkan data contoh."
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
return products.value.filter((product) => {
|
||||
const keyword = search.value.toLowerCase()
|
||||
const matchSearch =
|
||||
product.title.toLowerCase().includes(keyword) ||
|
||||
product.description.toLowerCase().includes(keyword) ||
|
||||
product.category.toLowerCase().includes(keyword)
|
||||
|
||||
const matchCategory =
|
||||
selectedCategory.value === "Semua" || product.category === selectedCategory.value
|
||||
|
||||
const matchCondition =
|
||||
selectedCondition.value === "Semua" || product.condition === selectedCondition.value
|
||||
|
||||
return matchSearch && matchCategory && matchCondition
|
||||
})
|
||||
})
|
||||
|
||||
function toggleSave(id) {
|
||||
if (saved.value.includes(id)) {
|
||||
saved.value = saved.value.filter((item) => item !== id)
|
||||
} else {
|
||||
saved.value.push(id)
|
||||
}
|
||||
setSavedProducts(saved.value)
|
||||
}
|
||||
|
||||
onMounted(loadProducts)
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<section class="container-page py-10">
|
||||
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
|
||||
← Kembali ke Marketplace
|
||||
</RouterLink>
|
||||
|
||||
<div v-if="loading" class="card p-10 text-center">
|
||||
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
|
||||
</div>
|
||||
|
||||
<div v-else-if="product" class="grid gap-8 lg:grid-cols-2">
|
||||
<div class="card overflow-hidden">
|
||||
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="rounded-full bg-brand-50 px-3 py-1 text-sm font-bold text-brand-700">
|
||||
{{ product.category }}
|
||||
</span>
|
||||
<h1 class="mt-4 text-3xl font-extrabold text-slate-950">{{ product.title }}</h1>
|
||||
<p class="mt-3 text-3xl font-extrabold text-brand-700">{{ formatRupiah(product.price) }}</p>
|
||||
|
||||
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<div class="card p-4">
|
||||
<p class="text-xs font-bold uppercase text-slate-500">Kondisi</p>
|
||||
<p class="mt-1 font-bold">{{ product.condition }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs font-bold uppercase text-slate-500">Lokasi</p>
|
||||
<p class="mt-1 font-bold">{{ product.location }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs font-bold uppercase text-slate-500">Penjual</p>
|
||||
<p class="mt-1 font-bold">{{ product.seller }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs font-bold uppercase text-slate-500">WhatsApp</p>
|
||||
<p class="mt-1 font-bold">{{ product.whatsapp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
||||
<p class="mt-2 leading-7 text-slate-600">{{ product.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<a :href="waLink" target="_blank" class="btn-primary">Hubungi via WhatsApp</a>
|
||||
<button class="btn-secondary" @click="toggleSave">
|
||||
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="card p-10 text-center">
|
||||
<h1 class="text-2xl font-extrabold">Produk tidak ditemukan</h1>
|
||||
<RouterLink to="/marketplace" class="btn-primary mt-5">Lihat Marketplace</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { products as defaultProducts } from "../data/products"
|
||||
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
|
||||
|
||||
const route = useRoute()
|
||||
const id = Number(route.params.id)
|
||||
const product = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const savedIds = ref(getSavedProducts())
|
||||
const saved = computed(() => savedIds.value.includes(id))
|
||||
|
||||
const waLink = computed(() => {
|
||||
if (!product.value) return "#"
|
||||
const text = encodeURIComponent(`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`)
|
||||
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
||||
})
|
||||
|
||||
function toggleSave() {
|
||||
if (saved.value) {
|
||||
savedIds.value = savedIds.value.filter((item) => item !== id)
|
||||
} else {
|
||||
savedIds.value.push(id)
|
||||
}
|
||||
setSavedProducts(savedIds.value)
|
||||
}
|
||||
|
||||
async function loadProduct() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchProduct(id)
|
||||
product.value = data.product
|
||||
} catch {
|
||||
product.value = defaultProducts.find((item) => Number(item.id) === id) || null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProduct)
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<section class="container-page grid min-h-[70vh] place-items-center py-10">
|
||||
<div class="card w-full max-w-md p-6">
|
||||
<h1 class="text-2xl font-extrabold">Register</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">Buat akun untuk mulai menjual barang.</p>
|
||||
|
||||
<form class="mt-6 grid gap-4" @submit.prevent="register">
|
||||
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<div>
|
||||
<label class="label">Nama Lengkap</label>
|
||||
<input v-model="form.name" class="input mt-2" placeholder="Nama kamu" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Email</label>
|
||||
<input v-model="form.email" type="email" class="input mt-2" placeholder="nama@email.com" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Nomor WhatsApp</label>
|
||||
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Password</label>
|
||||
<input v-model="form.password" type="password" class="input mt-2" placeholder="••••••••" required />
|
||||
</div>
|
||||
<button class="btn-primary w-full" :disabled="loading">
|
||||
{{ loading ? "Memproses..." : "Daftar" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-5 text-center text-sm text-slate-600">
|
||||
Sudah punya akun?
|
||||
<RouterLink to="/login" class="font-bold text-brand-700">Login</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { registerUser, setAuth } from "../utils"
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
email: "",
|
||||
whatsapp: "",
|
||||
password: ""
|
||||
})
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref("")
|
||||
|
||||
async function register() {
|
||||
loading.value = true
|
||||
errorMessage.value = ""
|
||||
|
||||
try {
|
||||
const data = await registerUser(form)
|
||||
setAuth(data)
|
||||
router.push("/dashboard")
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<section class="container-page py-10">
|
||||
<div class="grid gap-8 lg:grid-cols-[280px_1fr]">
|
||||
<DashboardSidebar />
|
||||
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-extrabold">Barang Tersimpan</h1>
|
||||
<p class="mt-2 text-slate-600">Barang yang kamu simpan dari marketplace.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card p-10 text-center">
|
||||
<h2 class="text-xl font-extrabold">Memuat barang tersimpan...</h2>
|
||||
</div>
|
||||
|
||||
<div v-else-if="savedProducts.length" class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ProductCard
|
||||
v-for="product in savedProducts"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
:saved="true"
|
||||
@toggle-save="toggleSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="card p-10 text-center">
|
||||
<h2 class="text-xl font-extrabold">Belum ada barang tersimpan</h2>
|
||||
<p class="mt-2 text-slate-600">Simpan barang yang menarik dari marketplace.</p>
|
||||
<RouterLink to="/marketplace" class="btn-primary mt-5">Lihat Marketplace</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import DashboardSidebar from "../components/DashboardSidebar.vue"
|
||||
import ProductCard from "../components/ProductCard.vue"
|
||||
import { products as defaultProducts } from "../data/products"
|
||||
import { fetchProducts, getSavedProducts, setSavedProducts } from "../utils"
|
||||
|
||||
const saved = ref(getSavedProducts())
|
||||
const products = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const savedProducts = computed(() => {
|
||||
return products.value.filter((product) => saved.value.includes(product.id))
|
||||
})
|
||||
|
||||
function toggleSave(id) {
|
||||
saved.value = saved.value.filter((item) => item !== id)
|
||||
setSavedProducts(saved.value)
|
||||
}
|
||||
|
||||
async function loadProducts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchProducts()
|
||||
products.value = data.products
|
||||
} catch {
|
||||
products.value = defaultProducts
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProducts)
|
||||
</script>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<section class="container-page py-10">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-extrabold">Jual Barang</h1>
|
||||
<p class="mt-2 text-slate-600">Posting barang ke marketplace dengan foto asli dari perangkatmu.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-[1fr_360px]">
|
||||
<form class="card grid gap-5 p-6" @submit.prevent="submitProduct">
|
||||
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="label">Nama Barang</label>
|
||||
<input v-model="form.title" class="input mt-2" placeholder="Contoh: Laptop ThinkPad T480" required />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="label">Kategori</label>
|
||||
<select v-model="form.category" class="input mt-2">
|
||||
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Harga</label>
|
||||
<input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="label">Kondisi</label>
|
||||
<select v-model="form.condition" class="input mt-2">
|
||||
<option>Bekas Normal</option>
|
||||
<option>Bekas Mulus</option>
|
||||
<option>Bekas Minus</option>
|
||||
<option>Bekas Server Room</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Lokasi</label>
|
||||
<input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="label">Nama Penjual</label>
|
||||
<input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Nomor WhatsApp</label>
|
||||
<input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Foto Barang</label>
|
||||
<input type="file" accept="image/*" class="input mt-2" required @change="handleImageChange" />
|
||||
<img v-if="previewUrl" :src="previewUrl" alt="Preview foto barang" class="mt-4 aspect-[4/3] w-full max-w-sm rounded-xl object-cover" />
|
||||
<p class="mt-2 text-xs text-slate-500">Upload JPG, PNG, atau WebP. Maksimal 3MB.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Deskripsi</label>
|
||||
<textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Jelaskan kondisi barang..." required></textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary w-full" :disabled="loading">
|
||||
{{ loading ? "Memposting..." : "Posting Barang" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="card h-fit p-6">
|
||||
<h2 class="text-lg font-extrabold">Tips Isi Produk</h2>
|
||||
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600">
|
||||
<li>• Pakai judul yang jelas, misal “MikroTik RB941 Bekas Normal”.</li>
|
||||
<li>• Jelaskan minus barang kalau ada.</li>
|
||||
<li>• Pakai nomor WhatsApp aktif.</li>
|
||||
<li>• Pakai foto yang terang dan jelas.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, computed, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { categories } from "../data/products"
|
||||
import { createProduct, getCurrentUser } from "../utils"
|
||||
|
||||
const router = useRouter()
|
||||
const realCategories = computed(() => categories.filter((cat) => cat !== "Semua"))
|
||||
|
||||
const form = reactive({
|
||||
title: "",
|
||||
category: "Laptop",
|
||||
price: "",
|
||||
condition: "Bekas Normal",
|
||||
location: "",
|
||||
seller: "",
|
||||
whatsapp: "",
|
||||
description: ""
|
||||
})
|
||||
const selectedImage = ref(null)
|
||||
const previewUrl = ref("")
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref("")
|
||||
|
||||
const currentUser = getCurrentUser()
|
||||
if (currentUser) {
|
||||
form.seller = currentUser.name || ""
|
||||
form.whatsapp = currentUser.whatsapp || ""
|
||||
}
|
||||
|
||||
function handleImageChange(event) {
|
||||
const file = event.target.files?.[0]
|
||||
selectedImage.value = file || null
|
||||
previewUrl.value = file ? URL.createObjectURL(file) : ""
|
||||
}
|
||||
|
||||
async function submitProduct() {
|
||||
if (!selectedImage.value) {
|
||||
errorMessage.value = "Foto barang wajib diupload."
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ""
|
||||
|
||||
const payload = new FormData()
|
||||
Object.entries(form).forEach(([key, value]) => {
|
||||
payload.append(key, value)
|
||||
})
|
||||
payload.append("image", selectedImage.value)
|
||||
|
||||
try {
|
||||
await createProduct(payload)
|
||||
router.push("/dashboard")
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user