Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1cb94689 | |||
| 5b40053903 | |||
| 000807d646 | |||
| ee74ab393c | |||
| 0f66595775 | |||
| d855f2e75a | |||
| e359365139 |
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
email VARCHAR(160) NOT NULL UNIQUE,
|
email VARCHAR(160) NOT NULL UNIQUE,
|
||||||
whatsapp VARCHAR(30) NOT NULL,
|
whatsapp VARCHAR(30) NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM('user', 'superadmin') NOT NULL DEFAULT 'user',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+8
-6
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+98
-2
@@ -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:
|
||||||
|
|||||||
+45
-3
@@ -58,7 +58,8 @@ function publicUser(user) {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
whatsapp: user.whatsapp
|
whatsapp: user.whatsapp,
|
||||||
|
role: user.role || "user"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ app.post("/api/auth/register", async (req, res, next) => {
|
|||||||
[name, email.toLowerCase(), whatsapp, passwordHash]
|
[name, email.toLowerCase(), whatsapp, passwordHash]
|
||||||
)
|
)
|
||||||
|
|
||||||
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp }
|
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" }
|
||||||
res.status(201).json({ token: signUser(user), user: publicUser(user) })
|
res.status(201).json({ token: signUser(user), user: publicUser(user) })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === "ER_DUP_ENTRY") {
|
if (error.code === "ER_DUP_ENTRY") {
|
||||||
@@ -152,7 +153,7 @@ app.post("/api/auth/login", async (req, res, next) => {
|
|||||||
|
|
||||||
app.get("/api/auth/me", auth, async (req, res, next) => {
|
app.get("/api/auth/me", auth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query("SELECT id, name, email, whatsapp FROM users WHERE id = ?", [req.user.id])
|
const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id])
|
||||||
if (!rows[0]) {
|
if (!rows[0]) {
|
||||||
res.status(404).json({ message: "User tidak ditemukan." })
|
res.status(404).json({ message: "User tidak ditemukan." })
|
||||||
return
|
return
|
||||||
@@ -261,6 +262,28 @@ app.delete("/api/products/:id", auth, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
|
||||||
|
|
||||||
|
if (product.image.startsWith("/uploads/")) {
|
||||||
|
fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
app.use((error, req, res, next) => {
|
app.use((error, req, res, next) => {
|
||||||
if (error instanceof multer.MulterError) {
|
if (error instanceof multer.MulterError) {
|
||||||
res.status(400).json({ message: "Upload gagal. Ukuran foto maksimal 3MB." })
|
res.status(400).json({ message: "Upload gagal. Ukuran foto maksimal 3MB." })
|
||||||
@@ -279,3 +302,22 @@ app.use((error, req, res, next) => {
|
|||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`SecondTech API berjalan di http://localhost:${port}`)
|
console.log(`SecondTech API berjalan di http://localhost:${port}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async function superadminOnly(req, res, next) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT role FROM users WHERE id = ?",
|
||||||
|
[req.user.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!rows[0] || rows[0].role !== "superadmin") {
|
||||||
|
res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 826 KiB |
@@ -1,8 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="card p-4">
|
<aside class="card p-4">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<p class="text-xs font-bold uppercase tracking-wider text-slate-500">Menu User</p>
|
<p class="text-xs font-bold uppercase tracking-wider text-slate-500">
|
||||||
|
Menu User
|
||||||
|
</p>
|
||||||
<h2 class="mt-1 text-lg font-extrabold">Dashboard</h2>
|
<h2 class="mt-1 text-lg font-extrabold">Dashboard</h2>
|
||||||
|
|
||||||
|
<div v-if="currentUser" class="mt-4 rounded-xl bg-slate-50 p-3">
|
||||||
|
<p class="text-sm font-bold text-slate-800">
|
||||||
|
{{ currentUser.name }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
{{ currentUser.email }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="isSuperadmin"
|
||||||
|
class="mt-2 inline-flex rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700"
|
||||||
|
>
|
||||||
|
Superadmin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="grid gap-2">
|
<nav class="grid gap-2">
|
||||||
@@ -10,18 +27,31 @@
|
|||||||
<LayoutDashboard size="18" />
|
<LayoutDashboard size="18" />
|
||||||
Barang Saya
|
Barang Saya
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink class="side-link" to="/jual">
|
<RouterLink class="side-link" to="/jual">
|
||||||
<PlusCircle size="18" />
|
<PlusCircle size="18" />
|
||||||
Jual Barang
|
Jual Barang
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink class="side-link" to="/marketplace">
|
<RouterLink class="side-link" to="/marketplace">
|
||||||
<Store size="18" />
|
<Store size="18" />
|
||||||
Marketplace
|
Marketplace
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink class="side-link" to="/tersimpan">
|
<RouterLink class="side-link" to="/tersimpan">
|
||||||
<Heart size="18" />
|
<Heart size="18" />
|
||||||
Barang Tersimpan
|
Barang Tersimpan
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-if="isSuperadmin"
|
||||||
|
class="side-link admin-link"
|
||||||
|
to="/admin/products"
|
||||||
|
>
|
||||||
|
<ShieldCheck size="18" />
|
||||||
|
Admin Marketplace
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
<button class="side-link text-left" type="button" @click="logout">
|
<button class="side-link text-left" type="button" @click="logout">
|
||||||
<LogOut size="18" />
|
<LogOut size="18" />
|
||||||
Logout
|
Logout
|
||||||
@@ -31,22 +61,66 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter } from "vue-router"
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
import { Heart, LayoutDashboard, LogOut, PlusCircle, Store } from "lucide-vue-next"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { clearAuth } from "../utils"
|
import {
|
||||||
|
Heart,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
PlusCircle,
|
||||||
|
ShieldCheck,
|
||||||
|
Store
|
||||||
|
} from "lucide-vue-next"
|
||||||
|
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const currentUser = ref(getCurrentUser())
|
||||||
|
|
||||||
|
const isSuperadmin = computed(() => {
|
||||||
|
return currentUser.value?.role === "superadmin"
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
currentUser.value = getCurrentUser()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async function refreshCurrentUser() {
|
||||||
|
if (!getToken()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchCurrentUser()
|
||||||
|
setAuth({
|
||||||
|
token: getToken(),
|
||||||
|
user: data.user
|
||||||
|
})
|
||||||
|
currentUser.value = getCurrentUser()
|
||||||
|
} catch {
|
||||||
|
clearAuth()
|
||||||
|
currentUser.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearAuth()
|
clearAuth()
|
||||||
router.push("/login")
|
router.push("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(refreshCurrentUser)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.side-link {
|
.side-link {
|
||||||
@apply flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-100;
|
@apply flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-link {
|
||||||
|
@apply bg-red-50 text-red-700 hover:bg-red-100;
|
||||||
|
}
|
||||||
|
|
||||||
.router-link-active {
|
.router-link-active {
|
||||||
@apply bg-brand-50 text-brand-700;
|
@apply bg-brand-50 text-brand-700;
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-14
@@ -18,16 +18,41 @@
|
|||||||
<RouterLink class="nav-link" to="/jual">Jual Barang</RouterLink>
|
<RouterLink class="nav-link" to="/jual">Jual Barang</RouterLink>
|
||||||
<RouterLink class="nav-link" to="/dashboard">Dashboard</RouterLink>
|
<RouterLink class="nav-link" to="/dashboard">Dashboard</RouterLink>
|
||||||
<RouterLink class="nav-link" to="/tersimpan">Tersimpan</RouterLink>
|
<RouterLink class="nav-link" to="/tersimpan">Tersimpan</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-if="isSuperadmin"
|
||||||
|
class="nav-link"
|
||||||
|
to="/admin/products"
|
||||||
|
>
|
||||||
|
Admin Marketplace
|
||||||
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="hidden items-center gap-3 md:flex">
|
<div class="hidden items-center gap-3 md:flex">
|
||||||
<template v-if="currentUser">
|
<template v-if="currentUser">
|
||||||
<span class="text-sm font-bold text-slate-700">{{ currentUser.name }}</span>
|
<span class="text-sm font-bold text-slate-700">
|
||||||
<button class="btn-secondary !px-4 !py-2" @click="logout">Logout</button>
|
{{ currentUser.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="isSuperadmin"
|
||||||
|
class="rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700"
|
||||||
|
>
|
||||||
|
Superadmin
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button class="btn-secondary !px-4 !py-2" @click="logout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">Login</RouterLink>
|
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">
|
||||||
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink>
|
Login
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/register" class="btn-primary !px-4 !py-2">
|
||||||
|
Register
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,17 +63,42 @@
|
|||||||
|
|
||||||
<div v-if="open" class="border-t border-slate-200 bg-white md:hidden">
|
<div v-if="open" class="border-t border-slate-200 bg-white md:hidden">
|
||||||
<div class="container-page grid gap-2 py-4">
|
<div class="container-page grid gap-2 py-4">
|
||||||
<RouterLink class="mobile-link" to="/" @click="open=false">Home</RouterLink>
|
<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="/marketplace" @click="open = false">Marketplace</RouterLink>
|
||||||
<RouterLink class="mobile-link" to="/jual" @click="open=false">Jual Barang</RouterLink>
|
<RouterLink class="mobile-link" to="/jual" @click="open = false">Jual Barang</RouterLink>
|
||||||
<RouterLink class="mobile-link" to="/dashboard" @click="open=false">Dashboard</RouterLink>
|
<RouterLink class="mobile-link" to="/dashboard" @click="open = false">Dashboard</RouterLink>
|
||||||
<RouterLink class="mobile-link" to="/tersimpan" @click="open=false">Tersimpan</RouterLink>
|
<RouterLink class="mobile-link" to="/tersimpan" @click="open = false">Tersimpan</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-if="isSuperadmin"
|
||||||
|
class="mobile-link"
|
||||||
|
to="/admin/products"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
Admin Marketplace
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
<div v-if="currentUser" class="mt-2 grid gap-2">
|
<div v-if="currentUser" class="mt-2 grid gap-2">
|
||||||
<button class="btn-secondary !py-2 text-center" @click="logout">Logout</button>
|
<div class="rounded-xl bg-slate-50 px-3 py-2">
|
||||||
|
<p class="text-sm font-bold text-slate-800">{{ currentUser.name }}</p>
|
||||||
|
<p class="text-xs text-slate-500">{{ currentUser.email }}</p>
|
||||||
|
<p v-if="isSuperadmin" class="mt-1 text-xs font-bold text-red-700">
|
||||||
|
Superadmin
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-secondary !py-2 text-center" @click="logout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="mt-2 grid grid-cols-2 gap-2">
|
<div v-else class="mt-2 grid grid-cols-2 gap-2">
|
||||||
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open=false">Login</RouterLink>
|
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open = false">
|
||||||
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open=false">Register</RouterLink>
|
Login
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">
|
||||||
|
Register
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,16 +106,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from "vue"
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { Cpu, Menu } from "lucide-vue-next"
|
import { Cpu, Menu } from "lucide-vue-next"
|
||||||
import { clearAuth, getCurrentUser } from "../utils"
|
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const currentUser = ref(getCurrentUser())
|
const currentUser = ref(getCurrentUser())
|
||||||
|
|
||||||
|
const isSuperadmin = computed(() => {
|
||||||
|
return currentUser.value?.role === "superadmin"
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
() => {
|
() => {
|
||||||
@@ -73,21 +127,41 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async function refreshCurrentUser() {
|
||||||
|
if (!getToken()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchCurrentUser()
|
||||||
|
setAuth({
|
||||||
|
token: getToken(),
|
||||||
|
user: data.user
|
||||||
|
})
|
||||||
|
currentUser.value = getCurrentUser()
|
||||||
|
} catch {
|
||||||
|
clearAuth()
|
||||||
|
currentUser.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearAuth()
|
clearAuth()
|
||||||
currentUser.value = null
|
currentUser.value = null
|
||||||
open.value = false
|
open.value = false
|
||||||
router.push("/login")
|
router.push("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(refreshCurrentUser)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.nav-link {
|
.nav-link {
|
||||||
@apply text-sm font-semibold text-slate-600 transition hover:text-brand-700;
|
@apply text-sm font-semibold text-slate-600 transition hover:text-brand-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.router-link-active {
|
.router-link-active {
|
||||||
@apply text-brand-700;
|
@apply text-brand-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-link {
|
.mobile-link {
|
||||||
@apply rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100;
|
@apply rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100;
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-9
@@ -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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
+14
-1
@@ -8,7 +8,8 @@ import DashboardView from "../views/DashboardView.vue"
|
|||||||
import SavedView from "../views/SavedView.vue"
|
import SavedView from "../views/SavedView.vue"
|
||||||
import LoginView from "../views/LoginView.vue"
|
import LoginView from "../views/LoginView.vue"
|
||||||
import RegisterView from "../views/RegisterView.vue"
|
import RegisterView from "../views/RegisterView.vue"
|
||||||
import { getToken } from "../utils"
|
import AdminProductsView from "../views/AdminProductsView.vue"
|
||||||
|
import { getCurrentUser, getToken } from "../utils"
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/", name: "home", component: HomeView },
|
{ path: "/", name: "home", component: HomeView },
|
||||||
@@ -17,6 +18,12 @@ const routes = [
|
|||||||
{ path: "/jual", name: "sell-product", component: SellProductView, meta: { requiresAuth: true } },
|
{ path: "/jual", name: "sell-product", component: SellProductView, meta: { requiresAuth: true } },
|
||||||
{ path: "/dashboard", name: "dashboard", component: DashboardView, meta: { requiresAuth: true } },
|
{ path: "/dashboard", name: "dashboard", component: DashboardView, meta: { requiresAuth: true } },
|
||||||
{ path: "/tersimpan", name: "saved", component: SavedView, meta: { requiresAuth: true } },
|
{ path: "/tersimpan", name: "saved", component: SavedView, meta: { requiresAuth: true } },
|
||||||
|
{
|
||||||
|
path: "/admin/products",
|
||||||
|
name: "admin-products",
|
||||||
|
component: AdminProductsView,
|
||||||
|
meta: { requiresAuth: true, requiresSuperadmin: true }
|
||||||
|
},
|
||||||
{ path: "/login", name: "login", component: LoginView },
|
{ path: "/login", name: "login", component: LoginView },
|
||||||
{ path: "/register", name: "register", component: RegisterView }
|
{ path: "/register", name: "register", component: RegisterView }
|
||||||
]
|
]
|
||||||
@@ -30,6 +37,12 @@ router.beforeEach((to) => {
|
|||||||
if (to.meta.requiresAuth && !getToken()) {
|
if (to.meta.requiresAuth && !getToken()) {
|
||||||
return "/login"
|
return "/login"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = getCurrentUser()
|
||||||
|
|
||||||
|
if (to.meta.requiresSuperadmin && user?.role !== "superadmin") {
|
||||||
|
return "/dashboard"
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
+19
-1
@@ -31,7 +31,14 @@ export function getCurrentUser() {
|
|||||||
|
|
||||||
export function setAuth({ token, user }) {
|
export function setAuth({ token, user }) {
|
||||||
localStorage.setItem("secondtech_token", token)
|
localStorage.setItem("secondtech_token", token)
|
||||||
setLocal("secondtech_user", user)
|
|
||||||
|
setLocal("secondtech_user", {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
whatsapp: user.whatsapp,
|
||||||
|
role: user.role || "user"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuth() {
|
export function clearAuth() {
|
||||||
@@ -56,6 +63,7 @@ async function requestJson(path, options = {}) {
|
|||||||
...options,
|
...options,
|
||||||
headers
|
headers
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -79,6 +87,10 @@ export function loginUser(payload) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchCurrentUser() {
|
||||||
|
return requestJson("/api/auth/me")
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchProducts() {
|
export function fetchProducts() {
|
||||||
return requestJson("/api/products")
|
return requestJson("/api/products")
|
||||||
}
|
}
|
||||||
@@ -111,6 +123,12 @@ export function deleteProductById(id) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteProductAsAdmin(id) {
|
||||||
|
return requestJson(`/api/admin/products/${id}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function getSavedProducts() {
|
export function getSavedProducts() {
|
||||||
return getLocal("secondtech_saved", [])
|
return getLocal("secondtech_saved", [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<section class="container-page py-10">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-extrabold">Admin Marketplace</h1>
|
||||||
|
<p class="mt-2 text-slate-600">Kelola semua produk yang tampil di marketplace.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="mb-5 rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card p-10 text-center">
|
||||||
|
<h2 class="text-xl font-extrabold">Memuat produk...</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="products.length" class="grid gap-4">
|
||||||
|
<div
|
||||||
|
v-for="product in products"
|
||||||
|
:key="product.id"
|
||||||
|
class="card grid gap-4 p-4 md:grid-cols-[140px_1fr_auto] md:items-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="getProductImageUrl(product.image)"
|
||||||
|
:alt="product.title"
|
||||||
|
class="aspect-[4/3] w-full rounded-xl object-cover md:w-36"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700">
|
||||||
|
{{ product.category }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
|
||||||
|
{{ product.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-3 font-extrabold">{{ product.title }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
{{ product.seller }} • {{ product.location }} • {{ product.condition }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 font-extrabold text-brand-700">
|
||||||
|
{{ formatRupiah(product.price) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 md:flex-col">
|
||||||
|
<RouterLink :to="`/produk/${product.id}`" class="btn-secondary !px-3 !py-2">
|
||||||
|
Detail
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
class="rounded-xl bg-red-50 px-3 py-2 text-sm font-bold text-red-700 hover:bg-red-100"
|
||||||
|
@click="deleteAsAdmin(product.id)"
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card p-10 text-center">
|
||||||
|
<h2 class="text-xl font-extrabold">Belum ada produk</h2>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from "vue"
|
||||||
|
import { API_BASE_URL, fetchProducts, formatRupiah, getProductImageUrl, getToken } from "../utils"
|
||||||
|
|
||||||
|
const products = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMessage = ref("")
|
||||||
|
|
||||||
|
async function loadProducts() {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchProducts()
|
||||||
|
products.value = data.products
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAsAdmin(id) {
|
||||||
|
if (!confirm("Yakin ingin menghapus produk ini dari marketplace?")) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/admin/products/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${getToken()}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || "Gagal menghapus produk.")
|
||||||
|
}
|
||||||
|
|
||||||
|
products.value = products.value.filter((product) => product.id !== id)
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadProducts)
|
||||||
|
</script>
|
||||||
+36
-7
@@ -38,12 +38,17 @@
|
|||||||
</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>
|
|
||||||
<p class="font-bold">{{ cat }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="font-bold">{{ cat.name }}</p>
|
||||||
|
</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)) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user