10 Commits

Author SHA1 Message Date
Grey 7e1cb94689 memperbaiki bug 2026-05-19 08:55:59 +07:00
Grey 5b40053903 mempercanti style animasi home 2026-05-19 08:49:40 +07:00
Grey 000807d646 Merge branch 'fitur-jualbarang' into dev 2026-05-15 19:19:44 +07:00
miko f118ca8489 mengubah deskripsi tulisan jualbarang 2026-05-15 18:51:20 +07:00
Grey ee74ab393c Merge branch 'fitur-home' into dev 2026-05-15 18:11:41 +07:00
Grey 0f66595775 Merge branch 'fitur-navbar' into dev 2026-05-15 18:09:00 +07:00
Grey d855f2e75a style:mengubah icon home view 2026-05-15 18:01:55 +07:00
miko f7dbc170c8 mengubah deskripsi tulisan pada card login 2026-05-15 18:00:21 +07:00
miko cbb8136cdd mengubah deskripsi tulisan pada homeView 2026-05-15 17:49:20 +07:00
miko 9b36480661 mengubah logo dan warna via WA 2026-05-15 17:40:09 +07:00
13 changed files with 508 additions and 35 deletions
+8 -6
View File
@@ -15,17 +15,19 @@
"cors": "latest", "cors": "latest",
"dotenv": "latest", "dotenv": "latest",
"express": "latest", "express": "latest",
"gsap": "^3.15.0",
"jsonwebtoken": "latest", "jsonwebtoken": "latest",
"lucide-vue-next": "latest",
"motion-v": "^1.10.3",
"multer": "latest",
"mysql2": "latest",
"vite": "latest", "vite": "latest",
"vue": "latest", "vue": "latest",
"vue-router": "latest", "vue-router": "latest"
"lucide-vue-next": "latest",
"multer": "latest",
"mysql2": "latest"
}, },
"devDependencies": { "devDependencies": {
"tailwindcss": "^3.4.17", "autoprefixer": "latest",
"postcss": "latest", "postcss": "latest",
"autoprefixer": "latest" "tailwindcss": "^3.4.17"
} }
} }
+98 -2
View File
@@ -23,12 +23,18 @@ importers:
express: express:
specifier: latest specifier: latest
version: 5.2.1 version: 5.2.1
gsap:
specifier: ^3.15.0
version: 3.15.0
jsonwebtoken: jsonwebtoken:
specifier: latest specifier: latest
version: 9.0.3 version: 9.0.3
lucide-vue-next: lucide-vue-next:
specifier: latest specifier: latest
version: 1.0.0(vue@3.5.34) version: 1.0.0(vue@3.5.34)
motion-v:
specifier: ^1.10.3
version: 1.10.3(@vueuse/core@14.3.0(vue@3.5.34))(vue@3.5.34)
multer: multer:
specifier: latest specifier: latest
version: 2.1.1 version: 2.1.1
@@ -235,6 +241,9 @@ packages:
'@types/node@25.7.0': '@types/node@25.7.0':
resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@vitejs/plugin-vue@6.0.6': '@vitejs/plugin-vue@6.0.6':
resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==} resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -289,6 +298,19 @@ packages:
'@vue/shared@3.5.34': '@vue/shared@3.5.34':
resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==}
'@vueuse/core@14.3.0':
resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.3.0':
resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==}
'@vueuse/shared@14.3.0':
resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==}
peerDependencies:
vue: ^3.5.0
accepts@2.0.0: accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -554,6 +576,20 @@ packages:
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.39.0:
resolution: {integrity: sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@2.0.0: fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -589,6 +625,9 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
has-symbols@1.1.0: has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -597,6 +636,9 @@ packages:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
hookable@5.5.3: hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -833,6 +875,18 @@ packages:
mlly@1.8.2: mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
motion-dom@12.39.0:
resolution: {integrity: sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==}
motion-utils@12.39.0:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
motion-v@1.10.3:
resolution: {integrity: sha512-9Ewo/wwGv7FO3PqYJpllBF/Efc7tbeM1iinVrM73s0RUQrnXHwMZCaRX98u4lu0PQCrZghPPfCsQ14pWKIEbnQ==}
peerDependencies:
'@vueuse/core': '>=10.0.0'
vue: '>=3.0.0'
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1398,6 +1452,8 @@ snapshots:
dependencies: dependencies:
undici-types: 7.21.0 undici-types: 7.21.0
'@types/web-bluetooth@0.0.21': {}
'@vitejs/plugin-vue@6.0.6(vite@8.0.12(@types/node@25.7.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.34)': '@vitejs/plugin-vue@6.0.6(vite@8.0.12(@types/node@25.7.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.34)':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13 '@rolldown/pluginutils': 1.0.0-rc.13
@@ -1481,6 +1537,19 @@ snapshots:
'@vue/shared@3.5.34': {} '@vue/shared@3.5.34': {}
'@vueuse/core@14.3.0(vue@3.5.34)':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.3.0
'@vueuse/shared': 14.3.0(vue@3.5.34)
vue: 3.5.34
'@vueuse/metadata@14.3.0': {}
'@vueuse/shared@14.3.0(vue@3.5.34)':
dependencies:
vue: 3.5.34
accepts@2.0.0: accepts@2.0.0:
dependencies: dependencies:
mime-types: 3.0.2 mime-types: 3.0.2
@@ -1744,6 +1813,12 @@ snapshots:
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
framer-motion@12.39.0:
dependencies:
motion-dom: 12.39.0
motion-utils: 12.39.0
tslib: 2.8.1
fresh@2.0.0: {} fresh@2.0.0: {}
fsevents@2.3.3: fsevents@2.3.3:
@@ -1783,12 +1858,16 @@ snapshots:
gopd@1.2.0: {} gopd@1.2.0: {}
gsap@3.15.0: {}
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
hasown@2.0.3: hasown@2.0.3:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
hey-listen@1.0.8: {}
hookable@5.5.3: {} hookable@5.5.3: {}
http-errors@2.0.1: http-errors@2.0.1:
@@ -1980,6 +2059,24 @@ snapshots:
pkg-types: 1.3.1 pkg-types: 1.3.1
ufo: 1.6.4 ufo: 1.6.4
motion-dom@12.39.0:
dependencies:
motion-utils: 12.39.0
motion-utils@12.39.0: {}
motion-v@1.10.3(@vueuse/core@14.3.0(vue@3.5.34))(vue@3.5.34):
dependencies:
'@vueuse/core': 14.3.0(vue@3.5.34)
framer-motion: 12.39.0
hey-listen: 1.0.8
motion-dom: 12.39.0
vue: 3.5.34
transitivePeerDependencies:
- '@emotion/is-prop-valid'
- react
- react-dom
ms@2.1.3: {} ms@2.1.3: {}
muggle-string@0.4.1: {} muggle-string@0.4.1: {}
@@ -2323,8 +2420,7 @@ snapshots:
ts-interface-checker@0.1.13: {} ts-interface-checker@0.1.13: {}
tslib@2.8.1: tslib@2.8.1: {}
optional: true
type-is@1.6.18: type-is@1.6.18:
dependencies: dependencies:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+6 -2
View File
@@ -2,8 +2,12 @@
<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur"> <header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
<div class="container-page flex h-16 items-center justify-between"> <div class="container-page flex h-16 items-center justify-between">
<RouterLink to="/" class="flex items-center gap-2"> <RouterLink to="/" class="flex items-center gap-2">
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-600 text-white"> <div class="flex items-center justify-center">
<Cpu size="20" /> <img
src="/round-logo-sija.png"
alt="SecondTech Logo"
class="h-10 w-10 object-contain"
/>
</div> </div>
<span class="text-lg font-extrabold tracking-tight">SecondTech</span> <span class="text-lg font-extrabold tracking-tight">SecondTech</span>
</RouterLink> </RouterLink>
+36 -9
View File
@@ -98,13 +98,40 @@ export const products = [
] ]
export const categories = [ export const categories = [
"Semua", {
"Laptop", name: "Semua",
"PC", icon: "Boxes"
"Monitor", },
"Keyboard", {
"Router", name: "Laptop",
"Switch", icon: "Laptop"
"Server", },
"Access Point" {
name: "PC",
icon: "PcCase"
},
{
name: "Monitor",
icon: "Monitor"
},
{
name: "Keyboard",
icon: "Keyboard"
},
{
name: "Router",
icon: "Router"
},
{
name: "Switch",
icon: "Network"
},
{
name: "Server",
icon: "Server"
},
{
name: "Access Point",
icon: "Wifi"
}
] ]
+4
View File
@@ -3,4 +3,8 @@ import App from "./App.vue"
import router from "./router" import router from "./router"
import "./assets/main.css" import "./assets/main.css"
if (localStorage.getItem("secondtech_theme") === "dark") {
document.documentElement.classList.add("dark")
}
createApp(App).use(router).mount("#app") createApp(App).use(router).mount("#app")
+37 -8
View File
@@ -6,10 +6,10 @@
Marketplace Barang Bekas Teknologi Marketplace Barang Bekas Teknologi
</span> </span>
<h1 class="mt-6 text-4xl font-extrabold tracking-tight text-slate-950 sm:text-5xl lg:text-6xl"> <h1 class="mt-6 text-4xl font-extrabold tracking-tight text-slate-950 sm:text-5xl lg:text-6xl">
Jual beli perangkat IT bekas jadi lebih mudah. Jual beli perangkat Teknologi bekas.
</h1> </h1>
<p class="mt-6 max-w-xl text-lg leading-8 text-slate-600"> <p class="mt-6 max-w-xl text-lg leading-8 text-slate-600">
Temukan laptop bekas, PC, monitor, keyboard, router, switch, access point, dan server bekas untuk kebutuhan belajar SIJA/TKJ. Temukan laptop bekas, PC, monitor, keyboard, router, switch, access point, dan server bekas untuk berbagai kebutuhan.
</p> </p>
<div class="mt-8 flex flex-wrap gap-3"> <div class="mt-8 flex flex-wrap gap-3">
<RouterLink to="/jual" class="btn-primary">Jual Barang Sekarang</RouterLink> <RouterLink to="/jual" class="btn-primary">Jual Barang Sekarang</RouterLink>
@@ -38,11 +38,16 @@
</div> </div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
<div v-for="cat in categoryList" :key="cat" class="card p-5 text-center transition hover:-translate-y-1 hover:shadow-md"> <div
v-for="cat in categoryList"
:key="cat.name"
class="card p-5 text-center transition hover:-translate-y-1 hover:shadow-md"
>
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-700"> <div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-700">
<Cpu size="22" /> <component :is="iconMap[cat.icon]" :size="22" />
</div> </div>
<p class="font-bold">{{ cat }}</p>
<p class="font-bold">{{ cat.name }}</p>
</div> </div>
</div> </div>
</section> </section>
@@ -71,14 +76,38 @@
<script setup> <script setup>
import { computed, ref } from "vue" import { computed, ref } from "vue"
import { Cpu } from "lucide-vue-next"
import ProductCard from "../components/ProductCard.vue" import ProductCard from "../components/ProductCard.vue"
import { products, categories } from "../data/products"
import { getSavedProducts, setSavedProducts } from "../utils" import { getSavedProducts, setSavedProducts } from "../utils"
import {
Boxes,
Laptop,
PcCase,
Monitor,
Keyboard,
Router,
Network,
Server,
Wifi
} from "lucide-vue-next"
import { products, categories } from "../data/products"
const saved = ref(getSavedProducts()) const saved = ref(getSavedProducts())
const categoryList = categories.filter((c) => c !== "Semua").slice(0, 6) const categoryList = categories
.filter((category) => category.name !== "Semua")
.slice(0, 6)
const latestProducts = computed(() => products.slice(0, 4)) const latestProducts = computed(() => products.slice(0, 4))
const iconMap = {
Boxes,
Laptop,
PcCase,
Monitor,
Keyboard,
Router,
Network,
Server,
Wifi
}
function toggleSave(id) { function toggleSave(id) {
if (saved.value.includes(id)) { if (saved.value.includes(id)) {
+2 -2
View File
@@ -1,8 +1,8 @@
<template> <template>
<section class="container-page grid min-h-[70vh] place-items-center py-10"> <section class="container-page grid min-h-[70vh] place-items-center py-10">
<div class="card w-full max-w-md p-6"> <div class="card w-full max-w-md p-6">
<h1 class="text-2xl font-extrabold">Login</h1> <h1 class="text-2xl font-extrabold">Selamat Datang Kembali!</h1>
<p class="mt-2 text-sm text-slate-600">Masuk untuk mengelola barang jualanmu.</p> <p class="mt-2 text-sm text-slate-600">Masuk dan lanjutkan jualanmu.</p>
<form class="mt-6 grid gap-4" @submit.prevent="login"> <form class="mt-6 grid gap-4" @submit.prevent="login">
<p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"> <p v-if="errorMessage" class="rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
+1 -1
View File
@@ -45,7 +45,7 @@
</div> </div>
<div class="mt-8 flex flex-wrap gap-3"> <div class="mt-8 flex flex-wrap gap-3">
<a :href="waLink" target="_blank" class="btn-primary">Hubungi via WhatsApp</a> <a :href="waLink" target="_blank" class="btn-WA">Hubungi via WhatsApp</a>
<button class="btn-secondary" @click="toggleSave"> <button class="btn-secondary" @click="toggleSave">
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }} {{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
</button> </button>
+2 -2
View File
@@ -2,7 +2,7 @@
<section class="container-page py-10"> <section class="container-page py-10">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-extrabold">Jual Barang</h1> <h1 class="text-3xl font-extrabold">Jual Barang</h1>
<p class="mt-2 text-slate-600">Posting barang ke marketplace dengan foto asli dari perangkatmu.</p> <p class="mt-2 text-slate-600">Posting barang anda ke marketplace dengan foto produk asli.</p>
</div> </div>
<div class="grid gap-8 lg:grid-cols-[1fr_360px]"> <div class="grid gap-8 lg:grid-cols-[1fr_360px]">
@@ -65,7 +65,7 @@
<div> <div>
<label class="label">Deskripsi</label> <label class="label">Deskripsi</label>
<textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Jelaskan kondisi barang..." required></textarea> <textarea v-model="form.description" class="input mt-2 min-h-32" placeholder="Deskripsikan kondisi barang anda..." required></textarea>
</div> </div>
<button class="btn-primary w-full" :disabled="loading"> <button class="btn-primary w-full" :disabled="loading">
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v';
import { computed, ref, watch } from 'vue';
interface ShinyTextProps {
text: string;
disabled?: boolean;
speed?: number;
className?: string;
color?: string;
shineColor?: string;
spread?: number;
yoyo?: boolean;
pauseOnHover?: boolean;
direction?: 'left' | 'right';
delay?: number;
}
const props = withDefaults(defineProps<ShinyTextProps>(), {
disabled: false,
speed: 2,
className: '',
color: '#b5b5b5',
shineColor: '#ffffff',
spread: 120,
yoyo: false,
pauseOnHover: false,
direction: 'left',
delay: 0
});
const isPaused = ref(false);
const progress = useMotionValue(0);
const elapsedRef = ref(0);
const lastTimeRef = ref<number | null>(null);
const directionRef = ref(props.direction === 'left' ? 1 : -1);
const animationDuration = computed(() => props.speed * 1000);
const delayDuration = computed(() => props.delay * 1000);
useAnimationFrame(time => {
if (props.disabled || isPaused.value) {
lastTimeRef.value = null;
return;
}
if (lastTimeRef.value === null) {
lastTimeRef.value = time;
return;
}
const deltaTime = time - lastTimeRef.value;
lastTimeRef.value = time;
elapsedRef.value += deltaTime;
// Animation goes from 0 to 100
if (props.yoyo) {
const cycleDuration = animationDuration.value + delayDuration.value;
const fullCycle = cycleDuration * 2;
const cycleTime = elapsedRef.value % fullCycle;
if (cycleTime < animationDuration.value) {
// Forward animation: 0 -> 100
const p = (cycleTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else if (cycleTime < cycleDuration) {
// Delay at end
progress.set(directionRef.value === 1 ? 100 : 0);
} else if (cycleTime < cycleDuration + animationDuration.value) {
// Reverse animation: 100 -> 0
const reverseTime = cycleTime - cycleDuration;
const p = 100 - (reverseTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else {
// Delay at start
progress.set(directionRef.value === 1 ? 0 : 100);
}
} else {
const cycleDuration = animationDuration.value + delayDuration.value;
const cycleTime = elapsedRef.value % cycleDuration;
if (cycleTime < animationDuration.value) {
// Animation phase: 0 -> 100
const p = (cycleTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else {
// Delay phase - hold at end (shine off-screen)
progress.set(directionRef.value === 1 ? 100 : 0);
}
}
});
watch(
() => props.direction,
() => {
directionRef.value = props.direction === 'left' ? 1 : -1;
elapsedRef.value = 0;
progress.set(0);
},
{
immediate: true
}
);
const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`);
const handleMouseEnter = () => {
if (props.pauseOnHover) isPaused.value = true;
};
const handleMouseLeave = () => {
if (props.pauseOnHover) isPaused.value = false;
};
const gradientStyle = computed(() => ({
backgroundImage: `linear-gradient(${props.spread}deg, ${props.color} 0%, ${props.color} 35%, ${props.shineColor} 50%, ${props.color} 65%, ${props.color} 100%)`,
backgroundSize: '200% auto',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}));
</script>
<template>
<Motion
tag="span"
:class="['inline-block', className]"
:style="{ ...gradientStyle, backgroundPosition }"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
{{ text }}
</Motion>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed, useTemplateRef } from 'vue';
import { gsap } from 'gsap';
interface TextTypeProps {
className?: string;
showCursor?: boolean;
hideCursorWhileTyping?: boolean;
cursorCharacter?: string;
cursorBlinkDuration?: number;
cursorClassName?: string;
text: string | string[];
as?: string;
typingSpeed?: number;
initialDelay?: number;
pauseDuration?: number;
deletingSpeed?: number;
loop?: boolean;
textColors?: string[];
variableSpeed?: { min: number; max: number };
onSentenceComplete?: (sentence: string, index: number) => void;
startOnVisible?: boolean;
reverseMode?: boolean;
}
const props = withDefaults(defineProps<TextTypeProps>(), {
as: 'div',
typingSpeed: 50,
initialDelay: 0,
pauseDuration: 2000,
deletingSpeed: 30,
loop: true,
className: '',
showCursor: true,
hideCursorWhileTyping: false,
cursorCharacter: '|',
cursorBlinkDuration: 0.5,
textColors: () => [],
startOnVisible: false,
reverseMode: false
});
const displayedText = ref('');
const currentCharIndex = ref(0);
const isDeleting = ref(false);
const currentTextIndex = ref(0);
const isVisible = ref(!props.startOnVisible);
const cursorRef = useTemplateRef('cursorRef');
const containerRef = useTemplateRef('containerRef');
const textArray = computed(() => (Array.isArray(props.text) ? props.text : [props.text]));
const getRandomSpeed = () => {
if (!props.variableSpeed) return props.typingSpeed;
const { min, max } = props.variableSpeed;
return Math.random() * (max - min) + min;
};
const getCurrentTextColor = () => {
if (!props.textColors.length) return '#ffffff';
return props.textColors[currentTextIndex.value % props.textColors.length];
};
let timeout: ReturnType<typeof setTimeout> | null = null;
const clearTimeoutIfNeeded = () => {
if (timeout) clearTimeout(timeout);
};
const executeTypingAnimation = () => {
const currentText = textArray.value[currentTextIndex.value];
const processedText = props.reverseMode ? currentText.split('').reverse().join('') : currentText;
if (isDeleting.value) {
if (displayedText.value === '') {
isDeleting.value = false;
if (currentTextIndex.value === textArray.value.length - 1 && !props.loop) return;
props.onSentenceComplete?.(textArray.value[currentTextIndex.value], currentTextIndex.value);
currentTextIndex.value = (currentTextIndex.value + 1) % textArray.value.length;
currentCharIndex.value = 0;
timeout = setTimeout(() => {}, props.pauseDuration);
} else {
timeout = setTimeout(() => {
displayedText.value = displayedText.value.slice(0, -1);
}, props.deletingSpeed);
}
} else {
if (currentCharIndex.value < processedText.length) {
timeout = setTimeout(
() => {
displayedText.value += processedText[currentCharIndex.value];
currentCharIndex.value += 1;
},
props.variableSpeed ? getRandomSpeed() : props.typingSpeed
);
} else if (textArray.value.length > 1) {
timeout = setTimeout(() => {
isDeleting.value = true;
}, props.pauseDuration);
}
}
};
watch(
[displayedText, currentCharIndex, isDeleting, isVisible],
() => {
if (!isVisible.value) return;
clearTimeoutIfNeeded();
if (currentCharIndex.value === 0 && !isDeleting.value && displayedText.value === '') {
timeout = setTimeout(() => {
executeTypingAnimation();
}, props.initialDelay);
} else {
executeTypingAnimation();
}
},
{ immediate: true }
);
onMounted(() => {
if (props.showCursor && cursorRef.value) {
gsap.set(cursorRef.value, { opacity: 1 });
gsap.to(cursorRef.value, {
opacity: 0,
duration: props.cursorBlinkDuration,
repeat: -1,
yoyo: true,
ease: 'power2.inOut'
});
}
if (props.startOnVisible && containerRef.value) {
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) isVisible.value = true;
});
},
{ threshold: 0.1 }
);
if (containerRef.value instanceof Element) {
observer.observe(containerRef.value);
}
onBeforeUnmount(() => observer.disconnect());
}
});
onBeforeUnmount(() => {
clearTimeoutIfNeeded();
});
</script>
<template>
<component
:is="as"
ref="containerRef"
:class="`inline-block whitespace-pre-wrap tracking-tight ${className}`"
v-bind="$attrs"
>
<span class="inline" :style="{ color: getCurrentTextColor() }">
{{ displayedText }}
</span>
<span
v-if="showCursor"
ref="cursorRef"
:class="`ml-1 inline-block opacity-100 ${
hideCursorWhileTyping && (currentCharIndex < textArray[currentTextIndex].length || isDeleting) ? 'hidden' : ''
} ${cursorClassName}`"
>
{{ cursorCharacter }}
</span>
</component>
</template>