Compare commits
3 Commits
b20b46aa0f
...
4677d8c1ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 4677d8c1ee | |||
| 0c54b91ed2 | |||
| f0dd99b330 |
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 990 KiB |
|
Before Width: | Height: | Size: 826 KiB After Width: | Height: | Size: 826 KiB |
|
Before Width: | Height: | Size: 506 KiB After Width: | Height: | Size: 506 KiB |
@@ -4,9 +4,7 @@
|
|||||||
<div class="grid gap-8 md:grid-cols-3">
|
<div class="grid gap-8 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-extrabold">SecondTech Market</h2>
|
<h2 class="text-xl font-extrabold">SecondTech Market</h2>
|
||||||
<p class="mt-3 text-sm leading-6 text-slate-300">
|
<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>
|
||||||
Marketplace barang bekas teknologi untuk laptop, PC, monitor, keyboard, perangkat jaringan, dan server.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold">Navigasi</h3>
|
<h3 class="font-bold">Navigasi</h3>
|
||||||
@@ -19,11 +17,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold">Catatan Project</h3>
|
<h3 class="font-bold">Catatan Project</h3>
|
||||||
<p class="mt-3 text-sm leading-6 text-slate-300">
|
<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.
|
Project ini di buat unutk komunitas jual beli barang bekas teknologi, dengan fokus pada perangkat jaringan dan server. Dibuat menggunakan Vue.js, Tailwind CSS, dan Firebase untuk autentikasi dan database. Fitur utama meliputi
|
||||||
|
listing produk, dashboard pengguna, dan admin
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-10 text-xs text-slate-400">© 2026 SecondTech Market. Dibuat untuk project web.</p>
|
<p class="mt-10 text-center text-xs text-slate-400">© 2026 SecondTech Market. Dibuat untuk project web.</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+32
-34
@@ -2,9 +2,7 @@
|
|||||||
<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">
|
<img src="/round-logo-sija.png" alt="SecondTech logo" class="h-9 w-9 rounded-full object-cover" />
|
||||||
<Cpu size="20" />
|
|
||||||
</div>
|
|
||||||
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
@@ -52,7 +50,7 @@
|
|||||||
<button type="button" class="btn-secondary mt-2 !py-2 text-center" @click="toggleTheme">
|
<button type="button" class="btn-secondary mt-2 !py-2 text-center" @click="toggleTheme">
|
||||||
<Sun v-if="darkMode" size="18" />
|
<Sun v-if="darkMode" size="18" />
|
||||||
<Moon v-else size="18" />
|
<Moon v-else size="18" />
|
||||||
{{ darkMode ? "Light Mode" : "Dark Mode" }}
|
{{ darkMode ? 'Light Mode' : 'Dark Mode' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="currentUser" class="mt-2 grid gap-2">
|
<div v-if="currentUser" class="mt-2 grid gap-2">
|
||||||
@@ -74,60 +72,60 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from "vue"
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { Cpu, Menu, Moon, Sun } from "lucide-vue-next"
|
import { Menu, Moon, Sun } from 'lucide-vue-next';
|
||||||
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from '../utils';
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false);
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const currentUser = ref(getCurrentUser())
|
const currentUser = ref(getCurrentUser());
|
||||||
const darkMode = ref(localStorage.getItem("secondtech_theme") === "dark")
|
const darkMode = ref(localStorage.getItem('secondtech_theme') === 'dark');
|
||||||
|
|
||||||
const isSuperadmin = computed(() => currentUser.value?.role === "superadmin")
|
const isSuperadmin = computed(() => currentUser.value?.role === 'superadmin');
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
() => {
|
() => {
|
||||||
currentUser.value = getCurrentUser()
|
currentUser.value = getCurrentUser();
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function applyTheme() {
|
function applyTheme() {
|
||||||
document.documentElement.classList.toggle("dark", darkMode.value)
|
document.documentElement.classList.toggle('dark', darkMode.value);
|
||||||
localStorage.setItem("secondtech_theme", darkMode.value ? "dark" : "light")
|
localStorage.setItem('secondtech_theme', darkMode.value ? 'dark' : 'light');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
darkMode.value = !darkMode.value
|
darkMode.value = !darkMode.value;
|
||||||
applyTheme()
|
applyTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCurrentUser() {
|
async function refreshCurrentUser() {
|
||||||
if (!getToken()) return
|
if (!getToken()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchCurrentUser()
|
const data = await fetchCurrentUser();
|
||||||
setAuth({ token: getToken(), user: data.user })
|
setAuth({ token: getToken(), user: data.user });
|
||||||
currentUser.value = getCurrentUser()
|
currentUser.value = getCurrentUser();
|
||||||
} catch {
|
} catch {
|
||||||
clearAuth()
|
clearAuth();
|
||||||
currentUser.value = null
|
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(() => {
|
onMounted(() => {
|
||||||
applyTheme()
|
applyTheme();
|
||||||
refreshCurrentUser()
|
refreshCurrentUser();
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
+1
-98
@@ -1,101 +1,4 @@
|
|||||||
export const products = [
|
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 = [
|
export const categories = [
|
||||||
"Semua",
|
"Semua",
|
||||||
|
|||||||
+75
-25
@@ -14,6 +14,7 @@
|
|||||||
:pauseOnHover="true"
|
:pauseOnHover="true"
|
||||||
:yoyo="true"
|
:yoyo="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<TextType
|
<TextType
|
||||||
as="h2"
|
as="h2"
|
||||||
@@ -26,7 +27,11 @@
|
|||||||
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>
|
||||||
<RouterLink to="/marketplace" class="btn-secondary">Lihat Marketplace</RouterLink>
|
<RouterLink to="/marketplace" class="btn-secondary">Lihat Marketplace</RouterLink>
|
||||||
@@ -35,7 +40,11 @@
|
|||||||
|
|
||||||
<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="/Hero.jpeg"
|
||||||
|
alt="Tech marketplace"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -46,11 +55,17 @@
|
|||||||
<h2 class="text-2xl font-extrabold">Kategori Populer</h2>
|
<h2 class="text-2xl font-extrabold">Kategori Populer</h2>
|
||||||
<p class="mt-2 text-slate-600">Fokus ke perangkat teknologi dan jaringan.</p>
|
<p class="mt-2 text-slate-600">Fokus ke perangkat teknologi dan jaringan.</p>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink to="/marketplace" class="text-sm font-bold text-brand-700">Lihat semua</RouterLink>
|
<RouterLink to="/marketplace" class="text-sm font-bold text-brand-700">
|
||||||
|
Lihat semua
|
||||||
|
</RouterLink>
|
||||||
</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.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">
|
||||||
<component :is="iconMap[cat.icon]" :size="22" />
|
<component :is="iconMap[cat.icon]" :size="22" />
|
||||||
</div>
|
</div>
|
||||||
@@ -65,32 +80,56 @@
|
|||||||
<div class="mb-8 flex items-end justify-between gap-4">
|
<div class="mb-8 flex items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-extrabold">Produk Terbaru</h2>
|
<h2 class="text-2xl font-extrabold">Produk Terbaru</h2>
|
||||||
<p class="mt-2 text-slate-600">Contoh barang bekas teknologi yang tersedia.</p>
|
<p class="mt-2 text-slate-600">Barang terbaru yang tersedia di marketplace.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div v-if="latestProducts.length" 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" />
|
<ProductCard
|
||||||
|
v-for="product in latestProducts"
|
||||||
|
:key="product.id"
|
||||||
|
:product="product"
|
||||||
|
:saved="saved.includes(product.id)"
|
||||||
|
@toggle-save="toggleSave"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card p-10 text-center">
|
||||||
|
<h3 class="text-xl font-extrabold">Belum ada produk</h3>
|
||||||
|
<p class="mt-2 text-slate-600">Produk terbaru akan muncul setelah barang diposting.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onMounted, ref } from "vue"
|
||||||
import ProductCard from '../components/ProductCard.vue';
|
import { Boxes, Keyboard, Laptop, Monitor, Network, PcCase, Router, Server, Wifi } from "lucide-vue-next"
|
||||||
import { getSavedProducts, setSavedProducts } from '../utils';
|
import ProductCard from "../components/ProductCard.vue"
|
||||||
import { Boxes, Laptop, PcCase, Monitor, Keyboard, Router, Network, Server, Wifi } from 'lucide-vue-next';
|
import { categoryOptions, products as defaultProducts } from "../data/products"
|
||||||
|
import { fetchProducts, getSavedProducts, setSavedProducts } from "../utils"
|
||||||
|
import ShinyText from "../vuebits/ShinyText/ShinyText.vue"
|
||||||
|
import TextType from "../vuebits/TextType/TextType.vue"
|
||||||
|
|
||||||
import { products, categoryOptions } from '../data/products';
|
const saved = ref(getSavedProducts())
|
||||||
import ShinyText from '../vuebits/ShinyText/ShinyText.vue';
|
const products = ref(defaultProducts)
|
||||||
import TextType from '../vuebits/TextType/TextType.vue';
|
|
||||||
|
|
||||||
const saved = ref(getSavedProducts());
|
|
||||||
const categoryList = categoryOptions
|
const categoryList = categoryOptions
|
||||||
.filter((category) => category.name !== 'Semua')
|
.filter((category) => category.name !== "Semua")
|
||||||
.slice(0, 6);
|
.slice(0, 6)
|
||||||
const latestProducts = computed(() => products.slice(0, 4));
|
|
||||||
|
const latestProducts = computed(() => {
|
||||||
|
return [...products.value]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.created_at || 0).getTime()
|
||||||
|
const dateB = new Date(b.created_at || 0).getTime()
|
||||||
|
|
||||||
|
if (dateA !== dateB) return dateB - dateA
|
||||||
|
return Number(b.id) - Number(a.id)
|
||||||
|
})
|
||||||
|
.slice(0, 4)
|
||||||
|
})
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
Boxes,
|
Boxes,
|
||||||
Laptop,
|
Laptop,
|
||||||
@@ -100,16 +139,27 @@ const iconMap = {
|
|||||||
Router,
|
Router,
|
||||||
Network,
|
Network,
|
||||||
Server,
|
Server,
|
||||||
Wifi,
|
Wifi
|
||||||
};
|
}
|
||||||
|
|
||||||
function toggleSave(id) {
|
function toggleSave(id) {
|
||||||
if (saved.value.includes(id)) {
|
if (saved.value.includes(id)) {
|
||||||
saved.value = saved.value.filter((item) => item !== id);
|
saved.value = saved.value.filter((item) => item !== id)
|
||||||
} else {
|
} else {
|
||||||
saved.value.push(id);
|
saved.value.push(id)
|
||||||
}
|
}
|
||||||
setSavedProducts(saved.value);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
setSavedProducts(saved.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLatestProducts() {
|
||||||
|
try {
|
||||||
|
const data = await fetchProducts()
|
||||||
|
products.value = data.products?.length ? data.products : defaultProducts
|
||||||
|
} catch {
|
||||||
|
products.value = defaultProducts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadLatestProducts)
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<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">
|
||||||
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
|
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
|
||||||
</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-[minmax(0,1fr)_minmax(380px,520px)]">
|
||||||
<div class="grid gap-4 sm:grid-cols-[88px_1fr]">
|
<div class="grid items-start gap-4 sm:grid-cols-[88px_minmax(0,1fr)]">
|
||||||
<div class="order-2 flex gap-3 overflow-x-auto sm:order-1 sm:grid sm:max-h-[520px] sm:overflow-y-auto">
|
<div class="order-2 flex gap-3 overflow-x-auto sm:order-1 sm:grid sm:content-start sm:max-h-[78vh] sm:overflow-y-auto">
|
||||||
<button
|
<button
|
||||||
v-for="(image, index) in productImages"
|
v-for="(image, index) in productImages"
|
||||||
:key="`${image}-${index}`"
|
:key="`${image}-${index}`"
|
||||||
@@ -19,12 +19,20 @@
|
|||||||
:class="selectedImage === image ? 'border-brand-600' : 'border-slate-200'"
|
:class="selectedImage === image ? 'border-brand-600' : 'border-slate-200'"
|
||||||
@click="selectedImage = image"
|
@click="selectedImage = image"
|
||||||
>
|
>
|
||||||
<img :src="getProductImageUrl(image)" :alt="`${product.title} ${index + 1}`" class="h-full w-full object-cover" />
|
<img
|
||||||
|
:src="getProductImageUrl(image)"
|
||||||
|
:alt="`${product.title} ${index + 1}`"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card order-1 overflow-hidden sm:order-2">
|
<div class="order-1 sm:order-2">
|
||||||
<img :src="getProductImageUrl(selectedImage)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
|
<img
|
||||||
|
:src="getProductImageUrl(selectedImage)"
|
||||||
|
:alt="product.title"
|
||||||
|
class="block h-auto max-h-[78vh] w-auto max-w-full rounded-2xl object-contain shadow-sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,22 +40,31 @@
|
|||||||
<span class="rounded-full bg-brand-50 px-3 py-1 text-sm font-bold text-brand-700">
|
<span class="rounded-full bg-brand-50 px-3 py-1 text-sm font-bold text-brand-700">
|
||||||
{{ product.category }}
|
{{ product.category }}
|
||||||
</span>
|
</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>
|
<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="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Kondisi</p>
|
<p class="text-xs font-bold uppercase text-slate-500">Kondisi</p>
|
||||||
<p class="mt-1 font-bold">{{ product.condition }}</p>
|
<p class="mt-1 font-bold">{{ product.condition }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Lokasi</p>
|
<p class="text-xs font-bold uppercase text-slate-500">Lokasi</p>
|
||||||
<p class="mt-1 font-bold">{{ product.location }}</p>
|
<p class="mt-1 font-bold">{{ product.location }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Penjual</p>
|
<p class="text-xs font-bold uppercase text-slate-500">Penjual</p>
|
||||||
<p class="mt-1 font-bold">{{ product.seller }}</p>
|
<p class="mt-1 font-bold">{{ product.seller }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">WhatsApp</p>
|
<p class="text-xs font-bold uppercase text-slate-500">WhatsApp</p>
|
||||||
<p class="mt-1 font-bold">{{ product.whatsapp }}</p>
|
<p class="mt-1 font-bold">{{ product.whatsapp }}</p>
|
||||||
@@ -56,11 +73,16 @@
|
|||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
||||||
<p class="mt-2 leading-7 text-slate-600">{{ product.description }}</p>
|
<p class="mt-2 leading-7 text-slate-600">
|
||||||
|
{{ product.description }}
|
||||||
|
</p>
|
||||||
</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-primary">
|
||||||
|
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>
|
||||||
@@ -70,7 +92,9 @@
|
|||||||
|
|
||||||
<div v-else class="card p-10 text-center">
|
<div v-else class="card p-10 text-center">
|
||||||
<h1 class="text-2xl font-extrabold">Produk tidak ditemukan</h1>
|
<h1 class="text-2xl font-extrabold">Produk tidak ditemukan</h1>
|
||||||
<RouterLink to="/marketplace" class="btn-primary mt-5">Lihat Marketplace</RouterLink>
|
<RouterLink to="/marketplace" class="btn-primary mt-5">
|
||||||
|
Lihat Marketplace
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,17 +103,28 @@
|
|||||||
import { computed, onMounted, ref, watch } from "vue"
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
import { useRoute } from "vue-router"
|
import { useRoute } from "vue-router"
|
||||||
import { products as defaultProducts } from "../data/products"
|
import { products as defaultProducts } from "../data/products"
|
||||||
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
|
import {
|
||||||
|
fetchProduct,
|
||||||
|
formatRupiah,
|
||||||
|
getProductImageUrl,
|
||||||
|
getSavedProducts,
|
||||||
|
setSavedProducts
|
||||||
|
} from "../utils"
|
||||||
|
|
||||||
const route = useRoute()
|
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 selectedImage = ref("")
|
||||||
|
|
||||||
const productImages = computed(() => {
|
const productImages = computed(() => {
|
||||||
if (!product.value) return []
|
if (!product.value) return []
|
||||||
const images = product.value.images?.length ? product.value.images : [product.value.image]
|
|
||||||
|
const images = product.value.images?.length
|
||||||
|
? product.value.images
|
||||||
|
: [product.value.image]
|
||||||
|
|
||||||
return [...new Set(images.filter(Boolean))]
|
return [...new Set(images.filter(Boolean))]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,11 +139,18 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const savedIds = ref(getSavedProducts())
|
const savedIds = ref(getSavedProducts())
|
||||||
const saved = computed(() => savedIds.value.includes(id))
|
|
||||||
|
const saved = computed(() => {
|
||||||
|
return savedIds.value.includes(id)
|
||||||
|
})
|
||||||
|
|
||||||
const waLink = computed(() => {
|
const waLink = computed(() => {
|
||||||
if (!product.value) return "#"
|
if (!product.value) return "#"
|
||||||
const text = encodeURIComponent(`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`)
|
|
||||||
|
const text = encodeURIComponent(
|
||||||
|
`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`
|
||||||
|
)
|
||||||
|
|
||||||
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -118,17 +160,22 @@ function toggleSave() {
|
|||||||
} else {
|
} else {
|
||||||
savedIds.value.push(id)
|
savedIds.value.push(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
setSavedProducts(savedIds.value)
|
setSavedProducts(savedIds.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProduct() {
|
async function loadProduct() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
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
|
const fallbackProduct = defaultProducts.find((item) => Number(item.id) === id) || null
|
||||||
product.value = fallbackProduct ? { ...fallbackProduct, images: [fallbackProduct.image] } : null
|
|
||||||
|
product.value = fallbackProduct
|
||||||
|
? { ...fallbackProduct, images: [fallbackProduct.image] }
|
||||||
|
: null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user