7 Commits

Author SHA1 Message Date
Grey 57d57f7b91 Merge branch 'dev'
Deploy SecondTech / deploy (push) Successful in 4s
2026-06-01 20:26:59 +07:00
Grey 081c9ea521 Merge branch 'dev'
Deploy SecondTech / deploy (push) Successful in 4s
2026-05-26 09:03:27 +07:00
Grey f814f58e2d Merge branch 'dev'
Deploy SecondTech / deploy (push) Successful in 3s
2026-05-26 08:57:56 +07:00
root 3324365ea7 add secondtech deploy workflow
Deploy SecondTech / deploy (push) Successful in 4s
2026-05-25 10:25:25 +07:00
Grey ae729dc1d2 add production start script 2026-05-25 09:59:59 +07:00
Grey 4677d8c1ee Merge branch 'dev' 2026-05-19 13:35:10 +07:00
Grey b20b46aa0f test 2026-05-19 10:59:03 +07:00
7 changed files with 246 additions and 399 deletions
+38
View File
@@ -0,0 +1,38 @@
name: Deploy SecondTech
on:
push:
branches:
- main
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Pull latest code
run: |
cd /var/www/secondtech
git fetch origin main
git reset --hard origin/main
- name: Install dependencies
run: |
cd /var/www/secondtech
pnpm install
- name: Build frontend
run: |
cd /var/www/secondtech
pnpm run build
- name: Restart backend
run: |
cd /var/www/secondtech
pm2 restart secondtech-api || pm2 start "pnpm run start" --name secondtech-api
pm2 save
- name: Reload nginx
run: |
nginx -t
systemctl reload nginx
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"dev:api": "node server/server.js", "dev:api": "node server/server.js",
"build": "vite build", "build": "vite build",
"preview": "vite preview --host 0.0.0.0" "preview": "vite preview --host 0.0.0.0",
"start": "node server/server.js"
}, },
"dependencies": { "dependencies": {
"@vitejs/plugin-vue": "latest", "@vitejs/plugin-vue": "latest",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

+96 -171
View File
@@ -2,9 +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">Edit Barang</h1> <h1 class="text-3xl font-extrabold">Edit Barang</h1>
<p class="mt-2 text-slate-600"> <p class="mt-2 text-slate-600">Ubah informasi barang yang sudah kamu posting.</p>
Ubah informasi barang yang sudah kamu posting.
</p>
</div> </div>
<div v-if="loadingProduct" class="card p-10 text-center"> <div v-if="loadingProduct" class="card p-10 text-center">
@@ -13,51 +11,31 @@
<div v-else-if="notFound" class="card p-10 text-center"> <div v-else-if="notFound" class="card p-10 text-center">
<h2 class="text-xl font-extrabold">Barang tidak ditemukan</h2> <h2 class="text-xl font-extrabold">Barang tidak ditemukan</h2>
<p class="mt-2 text-slate-600"> <p class="mt-2 text-slate-600">Barang yang ingin kamu edit tidak ditemukan.</p>
Barang yang ingin kamu edit tidak ditemukan. <RouterLink to="/dashboard" class="btn-primary mt-5">Kembali ke Dashboard</RouterLink>
</p>
<RouterLink to="/dashboard" class="btn-primary mt-5"
>Kembali ke Dashboard</RouterLink
>
</div> </div>
<div v-else class="grid gap-8 lg:grid-cols-[1fr_360px]"> <div v-else class="grid gap-8 lg:grid-cols-[1fr_360px]">
<form class="card grid gap-5 p-6" @submit.prevent="submitEdit"> <form class="card grid gap-5 p-6" @submit.prevent="submitEdit">
<p <p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
v-if="errorMessage"
class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"
>
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<div> <div>
<label class="label">Nama Barang</label> <label class="label">Nama Barang</label>
<input <input v-model="form.title" class="input mt-2" placeholder="Contoh: Laptop ThinkPad T480" required />
v-model="form.title"
class="input mt-2"
placeholder="Contoh: Laptop ThinkPad T480"
required
/>
</div> </div>
<div class="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div> <div>
<label class="label">Kategori</label> <label class="label">Kategori</label>
<select v-model="form.category" class="input mt-2" required> <select v-model="form.category" class="input mt-2" required>
<option v-for="cat in realCategories" :key="cat" :value="cat"> <option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
{{ cat }}
</option>
</select> </select>
</div> </div>
<div> <div>
<label class="label">Harga</label> <label class="label">Harga</label>
<input <input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required />
v-model.number="form.price"
type="number"
class="input mt-2"
placeholder="2500000"
required
/>
</div> </div>
</div> </div>
@@ -73,41 +51,25 @@
</div> </div>
<div> <div>
<label class="label">Lokasi</label> <label class="label">Lokasi</label>
<input <input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required />
v-model="form.location"
class="input mt-2"
placeholder="Yogyakarta"
required
/>
</div> </div>
</div> </div>
<div class="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div> <div>
<label class="label">Nama Penjual</label> <label class="label">Nama Penjual</label>
<input <input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required />
v-model="form.seller"
class="input mt-2"
placeholder="Nama kamu"
required
/>
</div> </div>
<div> <div>
<label class="label">Nomor WhatsApp +62</label> <label class="label">Nomor WhatsApp</label>
<input <input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
v-model="form.whatsapp"
class="input mt-2"
placeholder="+6281234567890"
required
/>
</div> </div>
</div> </div>
<div> <div>
<label class="label">Foto Barang</label> <label class="label">Foto Barang</label>
<p v-if="existingImages.length" class="mb-3 text-sm text-slate-500"> <p v-if="existingImages.length" class="mb-3 text-sm text-slate-500">
{{ existingImages.length }} foto saat ini. Upload foto baru jika {{ existingImages.length }} foto saat ini. Upload foto baru jika ingin mengganti.
ingin mengganti.
</p> </p>
<input <input
@@ -119,67 +81,39 @@
@change="handleImageChange" @change="handleImageChange"
/> />
<div <div v-if="selectedImages.length" class="mt-3 flex items-center justify-between gap-3 rounded-xl bg-brand-50 px-4 py-3 text-sm font-bold text-brand-700">
v-if="selectedImages.length"
class="mt-3 flex items-center justify-between gap-3 rounded-xl bg-brand-50 px-4 py-3 text-sm font-bold text-brand-700"
>
<span>{{ selectedImages.length }} foto baru dipilih</span> <span>{{ selectedImages.length }} foto baru dipilih</span>
<button type="button" class="text-red-700" @click="clearImages"> <button type="button" class="text-red-700" @click="clearImages">Hapus semua</button>
Hapus semua
</button>
</div> </div>
<div <div v-if="previewUrls.length" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
v-if="previewUrls.length" <div v-for="(preview, index) in previewUrls" :key="preview.url" class="overflow-hidden rounded-xl border border-slate-200">
class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3" <img :src="preview.url" :alt="`Preview foto barang ${index + 1}`" class="aspect-[4/3] w-full object-cover" />
> <button type="button" class="w-full bg-red-50 px-3 py-2 text-xs font-bold text-red-700" @click="removeImage(index)">
<div
v-for="(preview, index) in previewUrls"
:key="preview.url"
class="overflow-hidden rounded-xl border border-slate-200"
>
<img
:src="preview.url"
:alt="`Preview foto barang ${index + 1}`"
class="aspect-[4/3] w-full object-cover"
/>
<button
type="button"
class="w-full bg-red-50 px-3 py-2 text-xs font-bold text-red-700"
@click="removeImage(index)"
>
Hapus foto Hapus foto
</button> </button>
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-slate-500"> <p class="mt-2 text-xs text-slate-500">
Maksimal 6 foto, 10 MB per foto. Biarkan kosong jika tidak ingin Maksimal 6 foto, 10 MB per foto. Biarkan kosong jika tidak ingin mengganti foto.
mengganti foto.
</p> </p>
</div> </div>
<div> <div>
<label class="label">Deskripsi</label> <label class="label">Deskripsi</label>
<textarea <textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Jelaskan kondisi barang..." required></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">
{{ loading ? 'Menyimpan...' : 'Simpan Perubahan' }} {{ loading ? "Menyimpan..." : "Simpan Perubahan" }}
</button> </button>
</form> </form>
<div class="card h-fit p-6"> <div class="card h-fit p-6">
<h2 class="text-lg font-extrabold">Tips Edit Produk</h2> <h2 class="text-lg font-extrabold">Tips Edit Produk</h2>
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600"> <ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600">
<li> <li>- Perbarui judul dan deskripsi jika ada perubahan kondisi barang.</li>
- Perbarui judul dan deskripsi jika ada perubahan kondisi barang.
</li>
<li>- Upload foto baru jika kondisi fisik barang berubah.</li> <li>- Upload foto baru jika kondisi fisik barang berubah.</li>
<li>- Pastikan nomor WhatsApp masih aktif.</li> <li>- Pastikan nomor WhatsApp masih aktif.</li>
</ul> </ul>
@@ -189,135 +123,126 @@
</template> </template>
<script setup> <script setup>
import { reactive, computed, ref, onMounted } from 'vue'; import { reactive, computed, ref, onMounted } from "vue"
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from "vue-router"
import { categories } from '../data/products'; import { categories } from "../data/products"
import { fetchProduct, updateProduct } from '../utils'; import { fetchProduct, updateProduct } from "../utils"
const route = useRoute(); const route = useRoute()
const router = useRouter(); const router = useRouter()
const realCategories = computed(() => const realCategories = computed(() => categories.filter((cat) => cat !== "Semua"))
categories.filter((cat) => cat !== 'Semua') const fileInput = ref(null)
); const MAX_IMAGES = 6
const fileInput = ref(null); const MAX_IMAGE_SIZE_MB = 10
const MAX_IMAGES = 6; const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024
const MAX_IMAGE_SIZE_MB = 10;
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024;
const form = reactive({ const form = reactive({
title: '', title: "",
category: 'Laptop', category: "Laptop",
price: '', price: "",
condition: 'Bekas Normal', condition: "Bekas Normal",
location: '', location: "",
seller: '', seller: "",
whatsapp: '', whatsapp: "",
description: '', description: ""
}); })
const existingImages = ref([]); const existingImages = ref([])
const selectedImages = ref([]); const selectedImages = ref([])
const previewUrls = ref([]); const previewUrls = ref([])
const loading = ref(false); const loading = ref(false)
const loadingProduct = ref(true); const loadingProduct = ref(true)
const notFound = ref(false); const notFound = ref(false)
const errorMessage = ref(''); const errorMessage = ref("")
function imageKey(file) { function imageKey(file) {
return `${file.name}-${file.size}-${file.lastModified}`; return `${file.name}-${file.size}-${file.lastModified}`
} }
function rebuildPreviews() { function rebuildPreviews() {
previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url)); previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url))
previewUrls.value = selectedImages.value.map((file) => ({ previewUrls.value = selectedImages.value.map((file) => ({
key: imageKey(file), key: imageKey(file),
url: URL.createObjectURL(file), url: URL.createObjectURL(file)
})); }))
} }
function handleImageChange(event) { function handleImageChange(event) {
const incomingFiles = Array.from(event.target.files || []); const incomingFiles = Array.from(event.target.files || [])
const oversizedFile = incomingFiles.find( const oversizedFile = incomingFiles.find((file) => file.size > MAX_IMAGE_SIZE)
(file) => file.size > MAX_IMAGE_SIZE
);
if (oversizedFile) { if (oversizedFile) {
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`; errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`
event.target.value = ''; event.target.value = ""
return; return
} }
const merged = [...selectedImages.value]; const merged = [...selectedImages.value]
incomingFiles.forEach((file) => { incomingFiles.forEach((file) => {
if ( if (!merged.some((item) => imageKey(item) === imageKey(file)) && merged.length < MAX_IMAGES) {
!merged.some((item) => imageKey(item) === imageKey(file)) && merged.push(file)
merged.length < MAX_IMAGES
) {
merged.push(file);
} }
}); })
selectedImages.value = merged; selectedImages.value = merged
rebuildPreviews(); rebuildPreviews()
errorMessage.value = ''; errorMessage.value = ""
event.target.value = ''; event.target.value = ""
} }
function removeImage(index) { function removeImage(index) {
selectedImages.value = selectedImages.value.filter( selectedImages.value = selectedImages.value.filter((_, itemIndex) => itemIndex !== index)
(_, itemIndex) => itemIndex !== index rebuildPreviews()
);
rebuildPreviews();
} }
function clearImages() { function clearImages() {
selectedImages.value = []; selectedImages.value = []
rebuildPreviews(); rebuildPreviews()
if (fileInput.value) fileInput.value.value = ''; if (fileInput.value) fileInput.value.value = ""
} }
async function submitEdit() { async function submitEdit() {
loading.value = true; loading.value = true
errorMessage.value = ''; errorMessage.value = ""
const payload = new FormData(); const payload = new FormData()
Object.entries(form).forEach(([key, value]) => { Object.entries(form).forEach(([key, value]) => {
payload.append(key, value); payload.append(key, value)
}); })
selectedImages.value.forEach((image) => { selectedImages.value.forEach((image) => {
payload.append('images', image); payload.append("images", image)
}); })
try { try {
await updateProduct(route.params.id, payload); await updateProduct(route.params.id, payload)
router.push('/dashboard'); router.push("/dashboard")
} catch (error) { } catch (error) {
errorMessage.value = error.message; errorMessage.value = error.message
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
onMounted(async () => { onMounted(async () => {
try { try {
const data = await fetchProduct(route.params.id); const data = await fetchProduct(route.params.id)
const product = data.product; const product = data.product
form.title = product.title; form.title = product.title
form.category = product.category; form.category = product.category
form.price = product.price; form.price = product.price
form.condition = product.condition; form.condition = product.condition
form.location = product.location; form.location = product.location
form.seller = product.seller; form.seller = product.seller
form.whatsapp = product.whatsapp; form.whatsapp = product.whatsapp
form.description = product.description; form.description = product.description
if (product.images && product.images.length) { if (product.images && product.images.length) {
existingImages.value = product.images; existingImages.value = product.images
} }
} catch { } catch {
notFound.value = true; notFound.value = true
} finally { } finally {
loadingProduct.value = false; loadingProduct.value = false
} }
}); })
</script> </script>
+29 -75
View File
@@ -2,119 +2,73 @@
<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">Register</h1> <h1 class="text-2xl font-extrabold">Register</h1>
<p class="mt-2 text-sm text-slate-600"> <p class="mt-2 text-sm text-slate-600">Buat akun untuk mulai menjual barang.</p>
Buat akun untuk mulai menjual barang.
</p>
<form class="mt-6 grid gap-4" @submit.prevent="register"> <form class="mt-6 grid gap-4" @submit.prevent="register">
<p <p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
v-if="errorMessage"
class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"
>
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<div> <div>
<label class="label">Nama Lengkap</label> <label class="label">Nama Lengkap</label>
<input <input v-model="form.name" class="input mt-2" placeholder="Nama kamu" required />
v-model="form.name"
class="input mt-2"
placeholder="Nama kamu"
required
/>
</div> </div>
<div> <div>
<label class="label">Email</label> <label class="label">Email</label>
<input <input v-model="form.email" type="email" class="input mt-2" placeholder="nama@email.com" required />
v-model="form.email"
type="email"
class="input mt-2"
placeholder="nama@email.com"
required
/>
</div> </div>
<div> <div>
<label class="label">Nomor WhatsApp</label> <label class="label">Nomor WhatsApp</label>
<input <input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
v-model="form.whatsapp"
class="input mt-2"
placeholder="+6281234567890"
required
/>
</div> </div>
<div> <div>
<label class="label">Password</label> <label class="label">Password</label>
<input <input v-model="form.password" type="password" class="input mt-2" placeholder="••••••••" required />
v-model="form.password"
type="password"
class="input mt-2"
placeholder="••••••••"
required
/>
</div> </div>
<button class="btn-primary w-full" :disabled="loading"> <button class="btn-primary w-full" :disabled="loading">
<svg <svg v-if="loading" class="mr-3 h-5 w-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
v-if="loading" <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
class="mr-3 h-5 w-5 animate-spin" <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
{{ loading ? 'Memproses...' : 'Daftar' }} {{ loading ? "Memproses..." : "Daftar" }}
</button> </button>
</form> </form>
<p class="mt-5 text-center text-sm text-slate-600"> <p class="mt-5 text-center text-sm text-slate-600">
Sudah punya akun? Sudah punya akun?
<RouterLink to="/login" class="font-bold text-brand-700" <RouterLink to="/login" class="font-bold text-brand-700">Login</RouterLink>
>Login</RouterLink
>
</p> </p>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { reactive, ref } from 'vue'; import { reactive, ref } from "vue"
import { useRouter } from 'vue-router'; import { useRouter } from "vue-router"
import { registerUser, setAuth } from '../utils'; import { registerUser, setAuth } from "../utils"
const router = useRouter(); const router = useRouter()
const form = reactive({ const form = reactive({
name: '', name: "",
email: '', email: "",
whatsapp: '', whatsapp: "",
password: '', password: ""
}); })
const loading = ref(false); const loading = ref(false)
const errorMessage = ref(''); const errorMessage = ref("")
async function register() { async function register() {
loading.value = true; loading.value = true
errorMessage.value = ''; errorMessage.value = ""
try { try {
const data = await registerUser(form); const data = await registerUser(form)
setAuth(data); setAuth(data)
router.push('/dashboard'); router.push("/dashboard")
} catch (error) { } catch (error) {
errorMessage.value = error.message; errorMessage.value = error.message
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
</script> </script>
+80 -151
View File
@@ -2,49 +2,30 @@
<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"> <p class="mt-2 text-slate-600">Posting barang ke marketplace dengan beberapa foto asli dari perangkatmu.</p>
Posting barang ke marketplace dengan beberapa foto asli dari
perangkatmu.
</p>
</div> </div>
<div class="grid gap-8 lg:grid-cols-[1fr_360px]"> <div class="grid gap-8 lg:grid-cols-[1fr_360px]">
<form class="card grid gap-5 p-6" @submit.prevent="submitProduct"> <form class="card grid gap-5 p-6" @submit.prevent="submitProduct">
<p <p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
v-if="errorMessage"
class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"
>
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<div> <div>
<label class="label">Nama Barang</label> <label class="label">Nama Barang</label>
<input <input v-model="form.title" class="input mt-2" placeholder="Contoh: Laptop ThinkPad T480" required />
v-model="form.title"
class="input mt-2"
placeholder="Contoh: Laptop ThinkPad T480"
required
/>
</div> </div>
<div class="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div> <div>
<label class="label">Kategori</label> <label class="label">Kategori</label>
<select v-model="form.category" class="input mt-2" required> <select v-model="form.category" class="input mt-2" required>
<option v-for="cat in realCategories" :key="cat" :value="cat"> <option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
{{ cat }}
</option>
</select> </select>
</div> </div>
<div> <div>
<label class="label">Harga</label> <label class="label">Harga</label>
<input <input v-model.number="form.price" type="number" class="input mt-2" placeholder="2500000" required />
v-model.number="form.price"
type="number"
class="input mt-2"
placeholder="2500000"
required
/>
</div> </div>
</div> </div>
@@ -60,33 +41,18 @@
</div> </div>
<div> <div>
<label class="label">Lokasi</label> <label class="label">Lokasi</label>
<input <input v-model="form.location" class="input mt-2" placeholder="Yogyakarta" required />
v-model="form.location"
class="input mt-2"
placeholder="Yogyakarta"
required
/>
</div> </div>
</div> </div>
<div class="grid gap-5 sm:grid-cols-2"> <div class="grid gap-5 sm:grid-cols-2">
<div> <div>
<label class="label">Nama Penjual</label> <label class="label">Nama Penjual</label>
<input <input v-model="form.seller" class="input mt-2" placeholder="Nama kamu" required />
v-model="form.seller"
class="input mt-2"
placeholder="Nama kamu"
required
/>
</div> </div>
<div> <div>
<label class="label">Nomor WhatsApp +62</label> <label class="label">Nomor WhatsApp</label>
<input <input v-model="form.whatsapp" class="input mt-2" placeholder="6281234567890" required />
v-model="form.whatsapp"
class="input mt-2"
placeholder="+6281234567890"
required
/>
</div> </div>
</div> </div>
@@ -101,67 +67,39 @@
@change="handleImageChange" @change="handleImageChange"
/> />
<div <div v-if="selectedImages.length" class="mt-3 flex items-center justify-between gap-3 rounded-xl bg-brand-50 px-4 py-3 text-sm font-bold text-brand-700">
v-if="selectedImages.length"
class="mt-3 flex items-center justify-between gap-3 rounded-xl bg-brand-50 px-4 py-3 text-sm font-bold text-brand-700"
>
<span>{{ selectedImages.length }} foto dipilih</span> <span>{{ selectedImages.length }} foto dipilih</span>
<button type="button" class="text-red-700" @click="clearImages"> <button type="button" class="text-red-700" @click="clearImages">Hapus semua</button>
Hapus semua
</button>
</div> </div>
<div <div v-if="previewUrls.length" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
v-if="previewUrls.length" <div v-for="(preview, index) in previewUrls" :key="preview.url" class="overflow-hidden rounded-xl border border-slate-200">
class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3" <img :src="preview.url" :alt="`Preview foto barang ${index + 1}`" class="aspect-[4/3] w-full object-cover" />
> <button type="button" class="w-full bg-red-50 px-3 py-2 text-xs font-bold text-red-700" @click="removeImage(index)">
<div
v-for="(preview, index) in previewUrls"
:key="preview.url"
class="overflow-hidden rounded-xl border border-slate-200"
>
<img
:src="preview.url"
:alt="`Preview foto barang ${index + 1}`"
class="aspect-[4/3] w-full object-cover"
/>
<button
type="button"
class="w-full bg-red-50 px-3 py-2 text-xs font-bold text-red-700"
@click="removeImage(index)"
>
Hapus foto Hapus foto
</button> </button>
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-slate-500"> <p class="mt-2 text-xs text-slate-500">
Maksimal 6 foto, 10 MB per foto. Kamu boleh pilih beberapa sekaligus Maksimal 6 foto, 10 MB per foto. Kamu boleh pilih beberapa sekaligus atau tambah satu per satu.
atau tambah satu per satu.
</p> </p>
</div> </div>
<div> <div>
<label class="label">Deskripsi</label> <label class="label">Deskripsi</label>
<textarea <textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Jelaskan kondisi barang..." required></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">
{{ loading ? 'Memposting...' : 'Posting Barang' }} {{ loading ? "Memposting..." : "Posting Barang" }}
</button> </button>
</form> </form>
<div class="card h-fit p-6"> <div class="card h-fit p-6">
<h2 class="text-lg font-extrabold">Tips Isi Produk</h2> <h2 class="text-lg font-extrabold">Tips Isi Produk</h2>
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600"> <ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600">
<li> <li>- Pakai judul yang jelas, misal "MikroTik RB941 Bekas Normal".</li>
- Pakai judul yang jelas, misal "MikroTik RB941 Bekas Normal".
</li>
<li>- Jelaskan minus barang kalau ada.</li> <li>- Jelaskan minus barang kalau ada.</li>
<li>- Pakai nomor WhatsApp aktif.</li> <li>- Pakai nomor WhatsApp aktif.</li>
<li>- Upload beberapa foto dari sisi yang berbeda.</li> <li>- Upload beberapa foto dari sisi yang berbeda.</li>
@@ -172,118 +110,109 @@
</template> </template>
<script setup> <script setup>
import { reactive, computed, ref } from 'vue'; import { reactive, computed, ref } from "vue"
import { useRouter } from 'vue-router'; import { useRouter } from "vue-router"
import { categories } from '../data/products'; import { categories } from "../data/products"
import { createProduct, getCurrentUser } from '../utils'; import { createProduct, getCurrentUser } from "../utils"
const router = useRouter(); const router = useRouter()
const realCategories = computed(() => const realCategories = computed(() => categories.filter((cat) => cat !== "Semua"))
categories.filter((cat) => cat !== 'Semua') const fileInput = ref(null)
); const MAX_IMAGES = 6
const fileInput = ref(null); const MAX_IMAGE_SIZE_MB = 10
const MAX_IMAGES = 6; const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024
const MAX_IMAGE_SIZE_MB = 10;
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024;
const form = reactive({ const form = reactive({
title: '', title: "",
category: 'Laptop', category: "Laptop",
price: '', price: "",
condition: 'Bekas Normal', condition: "Bekas Normal",
location: '', location: "",
seller: '', seller: "",
whatsapp: '', whatsapp: "",
description: '', description: ""
}); })
const selectedImages = ref([]); const selectedImages = ref([])
const previewUrls = ref([]); const previewUrls = ref([])
const loading = ref(false); const loading = ref(false)
const errorMessage = ref(''); const errorMessage = ref("")
const currentUser = getCurrentUser(); const currentUser = getCurrentUser()
if (currentUser) { if (currentUser) {
form.seller = currentUser.name || ''; form.seller = currentUser.name || ""
form.whatsapp = currentUser.whatsapp || ''; form.whatsapp = currentUser.whatsapp || ""
} }
function imageKey(file) { function imageKey(file) {
return `${file.name}-${file.size}-${file.lastModified}`; return `${file.name}-${file.size}-${file.lastModified}`
} }
function rebuildPreviews() { function rebuildPreviews() {
previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url)); previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url))
previewUrls.value = selectedImages.value.map((file) => ({ previewUrls.value = selectedImages.value.map((file) => ({
key: imageKey(file), key: imageKey(file),
url: URL.createObjectURL(file), url: URL.createObjectURL(file)
})); }))
} }
function handleImageChange(event) { function handleImageChange(event) {
const incomingFiles = Array.from(event.target.files || []); const incomingFiles = Array.from(event.target.files || [])
const oversizedFile = incomingFiles.find( const oversizedFile = incomingFiles.find((file) => file.size > MAX_IMAGE_SIZE)
(file) => file.size > MAX_IMAGE_SIZE
);
if (oversizedFile) { if (oversizedFile) {
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`; errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`
event.target.value = ''; event.target.value = ""
return; return
} }
const merged = [...selectedImages.value]; const merged = [...selectedImages.value]
incomingFiles.forEach((file) => { incomingFiles.forEach((file) => {
if ( if (!merged.some((item) => imageKey(item) === imageKey(file)) && merged.length < MAX_IMAGES) {
!merged.some((item) => imageKey(item) === imageKey(file)) && merged.push(file)
merged.length < MAX_IMAGES
) {
merged.push(file);
} }
}); })
selectedImages.value = merged; selectedImages.value = merged
rebuildPreviews(); rebuildPreviews()
errorMessage.value = ''; errorMessage.value = ""
event.target.value = ''; event.target.value = ""
} }
function removeImage(index) { function removeImage(index) {
selectedImages.value = selectedImages.value.filter( selectedImages.value = selectedImages.value.filter((_, itemIndex) => itemIndex !== index)
(_, itemIndex) => itemIndex !== index rebuildPreviews()
);
rebuildPreviews();
} }
function clearImages() { function clearImages() {
selectedImages.value = []; selectedImages.value = []
rebuildPreviews(); rebuildPreviews()
if (fileInput.value) fileInput.value.value = ''; if (fileInput.value) fileInput.value.value = ""
} }
async function submitProduct() { async function submitProduct() {
if (!selectedImages.value.length) { if (!selectedImages.value.length) {
errorMessage.value = 'Minimal 1 foto barang wajib diupload.'; errorMessage.value = "Minimal 1 foto barang wajib diupload."
return; return
} }
loading.value = true; loading.value = true
errorMessage.value = ''; errorMessage.value = ""
const payload = new FormData(); const payload = new FormData()
Object.entries(form).forEach(([key, value]) => { Object.entries(form).forEach(([key, value]) => {
payload.append(key, value); payload.append(key, value)
}); })
selectedImages.value.forEach((image) => { selectedImages.value.forEach((image) => {
payload.append('images', image); payload.append("images", image)
}); })
try { try {
await createProduct(payload); await createProduct(payload)
router.push('/dashboard'); router.push("/dashboard")
} catch (error) { } catch (error) {
errorMessage.value = error.message; errorMessage.value = error.message
} finally { } finally {
loading.value = false; loading.value = false
} }
} }
</script> </script>