Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f814f58e2d | |||
| 213930291e | |||
| 0168395347 | |||
| 08821eaff0 | |||
| 3324365ea7 | |||
| ae729dc1d2 | |||
| 4677d8c1ee | |||
| 0c54b91ed2 | |||
| f0dd99b330 | |||
| b20b46aa0f | |||
| 6c36f7565e | |||
| 7e1cb94689 | |||
| 5b40053903 | |||
| 000807d646 | |||
| f118ca8489 | |||
| ee74ab393c | |||
| 0f66595775 | |||
| d855f2e75a | |||
| f7dbc170c8 | |||
| cbb8136cdd | |||
| 9b36480661 |
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS product_images (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
product_id INT UNSIGNED NOT NULL,
|
||||||
|
image VARCHAR(255) NOT NULL,
|
||||||
|
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_product_images_product
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -56,3 +56,14 @@ ON DUPLICATE KEY UPDATE
|
|||||||
image = VALUES(image),
|
image = VALUES(image),
|
||||||
description = VALUES(description),
|
description = VALUES(description),
|
||||||
status = VALUES(status);
|
status = VALUES(status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_images (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
product_id INT UNSIGNED NOT NULL,
|
||||||
|
image VARCHAR(255) NOT NULL,
|
||||||
|
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_product_images_product
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|||||||
+10
-7
@@ -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",
|
||||||
@@ -15,17 +16,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:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 513 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
+163
-55
@@ -14,6 +14,8 @@ dotenv.config()
|
|||||||
const app = express()
|
const app = express()
|
||||||
const port = Number(process.env.PORT || 5000)
|
const port = Number(process.env.PORT || 5000)
|
||||||
const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret"
|
const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret"
|
||||||
|
const maxImageSizeMb = Number(process.env.MAX_IMAGE_SIZE_MB || 10)
|
||||||
|
const maxImageCount = Number(process.env.MAX_IMAGE_COUNT || 6)
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
const uploadsDir = path.join(__dirname, "uploads")
|
const uploadsDir = path.join(__dirname, "uploads")
|
||||||
@@ -39,7 +41,7 @@ const storage = multer.diskStorage({
|
|||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 3 * 1024 * 1024 },
|
limits: { fileSize: maxImageSizeMb * 1024 * 1024, files: maxImageCount },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (!file.mimetype.startsWith("image/")) {
|
if (!file.mimetype.startsWith("image/")) {
|
||||||
cb(new Error("File harus berupa gambar."))
|
cb(new Error("File harus berupa gambar."))
|
||||||
@@ -49,6 +51,21 @@ const upload = multer({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function ensureSchema() {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS product_images (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
product_id INT UNSIGNED NOT NULL,
|
||||||
|
image VARCHAR(255) NOT NULL,
|
||||||
|
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_product_images_product
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
function signUser(user) {
|
function signUser(user) {
|
||||||
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
|
return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" })
|
||||||
}
|
}
|
||||||
@@ -80,12 +97,81 @@ function auth(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueImages(files) {
|
||||||
|
const seen = new Set()
|
||||||
|
return files.filter((file) => {
|
||||||
|
const key = `${file.originalname}-${file.size}-${file.mimetype}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
}).slice(0, maxImageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadedImages(req) {
|
||||||
|
const files = [
|
||||||
|
...(req.files?.images || []),
|
||||||
|
...(req.files?.image || []),
|
||||||
|
...(req.files?.photos || [])
|
||||||
|
]
|
||||||
|
return uniqueImages(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProductImages(productId, fallbackImage) {
|
||||||
|
const [images] = await pool.query(
|
||||||
|
"SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC",
|
||||||
|
[productId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const imagePaths = images.map((item) => item.image).filter(Boolean)
|
||||||
|
if (imagePaths.length) return imagePaths
|
||||||
|
return fallbackImage ? [fallbackImage] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachProductImages(products) {
|
||||||
|
return Promise.all(
|
||||||
|
products.map(async (product) => ({
|
||||||
|
...product,
|
||||||
|
images: await getProductImages(product.id, product.image)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function findProductById(id) {
|
async function findProductById(id) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?",
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?",
|
||||||
[id]
|
[id]
|
||||||
)
|
)
|
||||||
return rows[0]
|
|
||||||
|
const product = rows[0]
|
||||||
|
if (!product) return null
|
||||||
|
|
||||||
|
product.images = await getProductImages(product.id, product.image)
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUploadedImages(product) {
|
||||||
|
const images = product.images?.length ? product.images : [product.image]
|
||||||
|
|
||||||
|
images.forEach((image) => {
|
||||||
|
if (image?.startsWith("/uploads/")) {
|
||||||
|
fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get("/api/health", async (req, res, next) => {
|
app.get("/api/health", async (req, res, next) => {
|
||||||
@@ -97,6 +183,24 @@ app.get("/api/health", async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get("/api/debug/products/:id/images", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const product = await findProductById(req.params.id)
|
||||||
|
if (!product) {
|
||||||
|
res.status(404).json({ message: "Produk tidak ditemukan." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
product_id: Number(req.params.id),
|
||||||
|
main_image: product.image,
|
||||||
|
image_count: product.images.length,
|
||||||
|
images: product.images
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.post("/api/auth/register", async (req, res, next) => {
|
app.post("/api/auth/register", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, email, whatsapp, password } = req.body
|
const { name, email, whatsapp, password } = req.body
|
||||||
@@ -169,7 +273,7 @@ app.get("/api/products", async (req, res, next) => {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC"
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC"
|
||||||
)
|
)
|
||||||
res.json({ products: rows })
|
res.json({ products: await attachProductImages(rows) })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error)
|
next(error)
|
||||||
}
|
}
|
||||||
@@ -181,7 +285,7 @@ app.get("/api/products/mine", auth, async (req, res, next) => {
|
|||||||
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC",
|
"SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC",
|
||||||
[req.user.id]
|
[req.user.id]
|
||||||
)
|
)
|
||||||
res.json({ products: rows })
|
res.json({ products: await attachProductImages(rows) })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error)
|
next(error)
|
||||||
}
|
}
|
||||||
@@ -200,27 +304,46 @@ app.get("/api/products/:id", async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/api/products", auth, upload.single("image"), async (req, res, next) => {
|
app.post(
|
||||||
try {
|
"/api/products",
|
||||||
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
auth,
|
||||||
|
upload.fields([
|
||||||
|
{ name: "images", maxCount: maxImageCount },
|
||||||
|
{ name: "image", maxCount: maxImageCount },
|
||||||
|
{ name: "photos", maxCount: maxImageCount }
|
||||||
|
]),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { title, category, price, condition, location, seller, whatsapp, description } = req.body
|
||||||
|
const uploadedImages = getUploadedImages(req)
|
||||||
|
|
||||||
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.file) {
|
console.log("Upload produk:", {
|
||||||
res.status(400).json({ message: "Semua field produk dan foto wajib diisi." })
|
title,
|
||||||
return
|
imageCount: uploadedImages.length,
|
||||||
|
files: uploadedImages.map((file) => file.originalname)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !uploadedImages.length) {
|
||||||
|
res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainImagePath = `/uploads/${uploadedImages[0].filename}`
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description]
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageRows = uploadedImages.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index])
|
||||||
|
await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows])
|
||||||
|
|
||||||
|
const product = await findProductById(result.insertId)
|
||||||
|
res.status(201).json({ product })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const imagePath = `/uploads/${req.file.filename}`
|
|
||||||
const [result] = await pool.query(
|
|
||||||
"INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
[req.user.id, title, category, Number(price), condition, location, seller, whatsapp, imagePath, description]
|
|
||||||
)
|
|
||||||
|
|
||||||
const product = await findProductById(result.insertId)
|
|
||||||
res.status(201).json({ product })
|
|
||||||
} catch (error) {
|
|
||||||
next(error)
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
app.patch("/api/products/:id/status", auth, async (req, res, next) => {
|
app.patch("/api/products/:id/status", auth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -251,10 +374,7 @@ app.delete("/api/products/:id", auth, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
|
await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id])
|
||||||
|
await deleteUploadedImages(product)
|
||||||
if (product.image.startsWith("/uploads/")) {
|
|
||||||
fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ message: "Produk berhasil dihapus." })
|
res.json({ message: "Produk berhasil dihapus." })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -272,10 +392,7 @@ app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
|
await pool.query("DELETE FROM products WHERE id = ?", [req.params.id])
|
||||||
|
await deleteUploadedImages(product)
|
||||||
if (product.image.startsWith("/uploads/")) {
|
|
||||||
fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
|
res.json({ message: "Produk berhasil dihapus oleh superadmin." })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -283,10 +400,9 @@ app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, nex
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
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 setiap foto maksimal ${maxImageSizeMb} MB dan maksimal ${maxImageCount} foto.` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,28 +412,20 @@ app.use((error, req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
res.status(500).json({ message: "Terjadi kesalahan pada server." })
|
res.status(500).json({
|
||||||
|
message: "Terjadi kesalahan pada server.",
|
||||||
|
detail: process.env.NODE_ENV === "production" ? undefined : error.message
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.listen(port, () => {
|
ensureSchema()
|
||||||
console.log(`SecondTech API berjalan di http://localhost:${port}`)
|
.then(() => {
|
||||||
})
|
app.listen(port, () => {
|
||||||
|
console.log(`SecondTech API berjalan di http://localhost:${port}`)
|
||||||
|
console.log(`Upload foto: maksimal ${maxImageSizeMb} MB per foto, maksimal ${maxImageCount} foto.`)
|
||||||
async function superadminOnly(req, res, next) {
|
})
|
||||||
try {
|
})
|
||||||
const [rows] = await pool.query(
|
.catch((error) => {
|
||||||
"SELECT role FROM users WHERE id = ?",
|
console.error("Gagal menyiapkan database:", error)
|
||||||
[req.user.id]
|
process.exit(1)
|
||||||
)
|
})
|
||||||
|
|
||||||
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.
|
After Width: | Height: | Size: 826 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 506 KiB |
@@ -39,3 +39,461 @@ body {
|
|||||||
.btn-WA {
|
.btn-WA {
|
||||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600;
|
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SecondTech dark mode */
|
||||||
|
html.dark body {
|
||||||
|
background: #121212;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark header {
|
||||||
|
background: rgba(18, 18, 18, 0.94);
|
||||||
|
border-color: rgba(37, 99, 235, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark main,
|
||||||
|
html.dark section {
|
||||||
|
background: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .card {
|
||||||
|
background: #181818;
|
||||||
|
border-color: rgba(37, 99, 235, 0.30);
|
||||||
|
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input {
|
||||||
|
background: #181818;
|
||||||
|
border-color: rgba(37, 99, 235, 0.34);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .btn-secondary {
|
||||||
|
background: #181818;
|
||||||
|
border-color: rgba(37, 99, 235, 0.38);
|
||||||
|
color: #f8fafc;
|
||||||
|
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .btn-primary {
|
||||||
|
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .bg-white,
|
||||||
|
html.dark .bg-slate-50,
|
||||||
|
html.dark .bg-slate-100 {
|
||||||
|
background-color: #181818;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-950,
|
||||||
|
html.dark .text-slate-900,
|
||||||
|
html.dark .text-slate-800,
|
||||||
|
html.dark .text-slate-700 {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-600,
|
||||||
|
html.dark .text-slate-500 {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .border-slate-200 {
|
||||||
|
border-color: rgba(37, 99, 235, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SecondTech stronger dark mode repair */
|
||||||
|
html.dark,
|
||||||
|
html.dark body {
|
||||||
|
background: #121212 !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark header,
|
||||||
|
html.dark footer,
|
||||||
|
html.dark main,
|
||||||
|
html.dark section {
|
||||||
|
background-color: #121212 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark header {
|
||||||
|
border-color: rgba(37, 99, 235, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .card {
|
||||||
|
background: #181818 !important;
|
||||||
|
border-color: rgba(37, 99, 235, 0.34) !important;
|
||||||
|
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input,
|
||||||
|
html.dark input,
|
||||||
|
html.dark select,
|
||||||
|
html.dark textarea {
|
||||||
|
background-color: #181818 !important;
|
||||||
|
border-color: rgba(37, 99, 235, 0.38) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input::placeholder,
|
||||||
|
html.dark input::placeholder,
|
||||||
|
html.dark textarea::placeholder {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .btn-secondary {
|
||||||
|
background: #181818 !important;
|
||||||
|
border-color: rgba(37, 99, 235, 0.42) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .btn-primary {
|
||||||
|
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .bg-white,
|
||||||
|
html.dark .bg-slate-50,
|
||||||
|
html.dark .bg-slate-100,
|
||||||
|
html.dark .bg-brand-50 {
|
||||||
|
background-color: #181818 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-950,
|
||||||
|
html.dark .text-slate-900,
|
||||||
|
html.dark .text-slate-800,
|
||||||
|
html.dark .text-slate-700,
|
||||||
|
html.dark .text-brand-900,
|
||||||
|
html.dark .text-brand-800,
|
||||||
|
html.dark .text-brand-700 {
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-600,
|
||||||
|
html.dark .text-slate-500,
|
||||||
|
html.dark .text-slate-400 {
|
||||||
|
color: #cbd5e1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .nav-link,
|
||||||
|
html.dark .mobile-link,
|
||||||
|
html.dark a:not(.btn-primary) {
|
||||||
|
color: #dbeafe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .nav-link:hover,
|
||||||
|
html.dark .mobile-link:hover,
|
||||||
|
html.dark a:not(.btn-primary):hover {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .border-slate-200,
|
||||||
|
html.dark .border-brand-600 {
|
||||||
|
border-color: rgba(37, 99, 235, 0.42) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .shadow-sm {
|
||||||
|
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SecondTech final dark mode repair */
|
||||||
|
html.dark,
|
||||||
|
html.dark body {
|
||||||
|
background: #121212 !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark header,
|
||||||
|
html.dark footer,
|
||||||
|
html.dark main,
|
||||||
|
html.dark section {
|
||||||
|
background-color: #121212 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark header {
|
||||||
|
border-color: rgba(37, 99, 235, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .card {
|
||||||
|
background: #181818 !important;
|
||||||
|
border-color: rgba(37, 99, 235, 0.34) !important;
|
||||||
|
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input,
|
||||||
|
html.dark input,
|
||||||
|
html.dark select,
|
||||||
|
html.dark textarea {
|
||||||
|
background-color: #181818 !important;
|
||||||
|
border-color: rgba(37, 99, 235, 0.38) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input::placeholder,
|
||||||
|
html.dark input::placeholder,
|
||||||
|
html.dark textarea::placeholder {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .btn-secondary {
|
||||||
|
background: #181818 !important;
|
||||||
|
border-color: rgba(37, 99, 235, 0.42) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .btn-primary {
|
||||||
|
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .bg-white,
|
||||||
|
html.dark .bg-slate-50,
|
||||||
|
html.dark .bg-slate-100,
|
||||||
|
html.dark .bg-brand-50,
|
||||||
|
html.dark .bg-emerald-50,
|
||||||
|
html.dark .bg-red-50 {
|
||||||
|
background-color: #181818 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-zinc-950,
|
||||||
|
html.dark .text-zinc-900,
|
||||||
|
html.dark .text-zinc-800,
|
||||||
|
html.dark .text-slate-950,
|
||||||
|
html.dark .text-slate-900,
|
||||||
|
html.dark .text-slate-800,
|
||||||
|
html.dark .text-slate-700,
|
||||||
|
html.dark .text-brand-900,
|
||||||
|
html.dark .text-brand-800,
|
||||||
|
html.dark .text-brand-700,
|
||||||
|
html.dark .text-emerald-700,
|
||||||
|
html.dark .text-red-700 {
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-600,
|
||||||
|
html.dark .text-slate-500,
|
||||||
|
html.dark .text-slate-400,
|
||||||
|
html.dark .text-zinc-600,
|
||||||
|
html.dark .text-zinc-500,
|
||||||
|
html.dark .text-zinc-400 {
|
||||||
|
color: #cbd5e1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .nav-link,
|
||||||
|
html.dark .mobile-link,
|
||||||
|
html.dark a:not(.btn-primary) {
|
||||||
|
color: #dbeafe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .nav-link:hover,
|
||||||
|
html.dark .mobile-link:hover,
|
||||||
|
html.dark a:not(.btn-primary):hover {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .border-slate-200,
|
||||||
|
html.dark .border-brand-600 {
|
||||||
|
border-color: rgba(37, 99, 235, 0.42) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .shadow-sm {
|
||||||
|
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SecondTech final dark mode override */
|
||||||
|
:root {
|
||||||
|
--hero-title-color: #0f1729;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
--hero-title-color: #f8fafc;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark,
|
||||||
|
html.dark body {
|
||||||
|
background: #121212 !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark header,
|
||||||
|
html.dark footer,
|
||||||
|
html.dark main,
|
||||||
|
html.dark section {
|
||||||
|
background-color: #121212 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark header {
|
||||||
|
border-color: rgba(37, 99, 235, 0.36) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .card {
|
||||||
|
background: #181818 !important;
|
||||||
|
border-color: rgba(37, 99, 235, 0.34) !important;
|
||||||
|
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input,
|
||||||
|
html.dark input,
|
||||||
|
html.dark select,
|
||||||
|
html.dark textarea {
|
||||||
|
background-color: #181818 !important;
|
||||||
|
border-color: rgba(37, 99, 235, 0.38) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .input::placeholder,
|
||||||
|
html.dark input::placeholder,
|
||||||
|
html.dark textarea::placeholder {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .btn-secondary {
|
||||||
|
background: #181818 !important;
|
||||||
|
border-color: rgba(37, 99, 235, 0.42) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .btn-primary {
|
||||||
|
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .bg-white,
|
||||||
|
html.dark .bg-slate-50,
|
||||||
|
html.dark .bg-slate-100,
|
||||||
|
html.dark .bg-brand-50,
|
||||||
|
html.dark .bg-emerald-50,
|
||||||
|
html.dark .bg-red-50 {
|
||||||
|
background-color: #181818 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .bg-white\/90 {
|
||||||
|
background-color: rgba(24, 24, 24, 0.9) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-zinc-950,
|
||||||
|
html.dark .text-zinc-900,
|
||||||
|
html.dark .text-zinc-800,
|
||||||
|
html.dark .text-slate-950,
|
||||||
|
html.dark .text-slate-900,
|
||||||
|
html.dark .text-slate-800,
|
||||||
|
html.dark .text-slate-700,
|
||||||
|
html.dark .text-brand-950,
|
||||||
|
html.dark .text-brand-900,
|
||||||
|
html.dark .text-brand-800,
|
||||||
|
html.dark .text-brand-700,
|
||||||
|
html.dark .text-emerald-700,
|
||||||
|
html.dark .text-red-700 {
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-600,
|
||||||
|
html.dark .text-slate-500,
|
||||||
|
html.dark .text-slate-400,
|
||||||
|
html.dark .text-zinc-600,
|
||||||
|
html.dark .text-zinc-500,
|
||||||
|
html.dark .text-zinc-400 {
|
||||||
|
color: #cbd5e1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .label,
|
||||||
|
html.dark label,
|
||||||
|
html.dark h1,
|
||||||
|
html.dark h2,
|
||||||
|
html.dark h3,
|
||||||
|
html.dark h4,
|
||||||
|
html.dark p,
|
||||||
|
html.dark span {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .nav-link,
|
||||||
|
html.dark .mobile-link,
|
||||||
|
html.dark a:not(.btn-primary) {
|
||||||
|
color: #dbeafe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .nav-link:hover,
|
||||||
|
html.dark .mobile-link:hover,
|
||||||
|
html.dark a:not(.btn-primary):hover {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .border-slate-200,
|
||||||
|
html.dark .border-brand-600 {
|
||||||
|
border-color: rgba(37, 99, 235, 0.42) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .shadow-sm {
|
||||||
|
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar active links are generated by scoped component CSS, so override directly. */
|
||||||
|
html.dark .side-link {
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .side-link.router-link-active {
|
||||||
|
background: rgba(37, 99, 235, 0.18) !important;
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.40) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.16) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .side-link.router-link-active svg,
|
||||||
|
html.dark .side-link svg {
|
||||||
|
color: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .side-link.admin-link,
|
||||||
|
html.dark .side-link.admin-link.router-link-active {
|
||||||
|
background: rgba(37, 99, 235, 0.18) !important;
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.40) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product cards: category badge and save button use bg-white/90. */
|
||||||
|
html.dark button.bg-white\/90,
|
||||||
|
html.dark span.bg-white\/90 {
|
||||||
|
background-color: rgba(24, 24, 24, 0.90) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark button.bg-white\/90:hover {
|
||||||
|
background-color: #2563eb !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .rounded-full.bg-brand-50,
|
||||||
|
html.dark .rounded-full.bg-emerald-50,
|
||||||
|
html.dark .rounded-full.bg-red-50 {
|
||||||
|
background-color: rgba(37, 99, 235, 0.16) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode sidebar hover fix */
|
||||||
|
html.dark .side-link:hover {
|
||||||
|
background: rgba(37, 99, 235, 0.16) !important;
|
||||||
|
border-color: rgba(37, 99, 235, 0.38) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .side-link:hover svg {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .side-link.router-link-active,
|
||||||
|
html.dark .side-link.router-link-active:hover {
|
||||||
|
background: rgba(37, 99, 235, 0.22) !important;
|
||||||
|
border: 1px solid rgba(37, 99, 235, 0.45) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.16) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
<div class="grid gap-8 md:grid-cols-3">
|
<div class="grid gap-8 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-extrabold">SecondTech Market</h2>
|
<h2 class="text-xl font-extrabold">SecondTech Market</h2>
|
||||||
<p class="mt-3 text-sm leading-6 text-slate-300">
|
<p class="mt-3 text-sm leading-6 text-slate-300">Marketplace barang bekas teknologi untuk laptop, PC, monitor, keyboard, perangkat jaringan, dan server.</p>
|
||||||
Marketplace barang bekas teknologi untuk laptop, PC, monitor, keyboard, perangkat jaringan, dan server.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold">Navigasi</h3>
|
<h3 class="font-bold">Navigasi</h3>
|
||||||
@@ -19,11 +17,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold">Catatan Project</h3>
|
<h3 class="font-bold">Catatan Project</h3>
|
||||||
<p class="mt-3 text-sm leading-6 text-slate-300">
|
<p class="mt-3 text-sm leading-6 text-slate-300">
|
||||||
Versi ini memakai backend Express, database MySQL, dan upload foto produk dari file perangkat.
|
Project ini di buat unutk komunitas jual beli barang bekas teknologi, dengan fokus pada perangkat jaringan dan server. Dibuat menggunakan Vue.js, Tailwind CSS, dan Firebase untuk autentikasi dan database. Fitur utama meliputi
|
||||||
|
listing produk, dashboard pengguna, dan admin
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-10 text-xs text-slate-400">© 2026 SecondTech Market. Dibuat untuk project web.</p>
|
<p class="mt-10 text-center text-xs text-slate-400">© 2026 SecondTech Market. Dibuat untuk project web.</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+59
-80
@@ -2,9 +2,7 @@
|
|||||||
<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
|
<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||||
<div class="container-page flex h-16 items-center justify-between">
|
<div class="container-page flex h-16 items-center justify-between">
|
||||||
<RouterLink to="/" class="flex items-center gap-2">
|
<RouterLink to="/" class="flex items-center gap-2">
|
||||||
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-600 text-white">
|
<img src="/round-logo-sija.png" alt="SecondTech logo" class="h-9 w-9 rounded-full object-cover" />
|
||||||
<Cpu size="20" />
|
|
||||||
</div>
|
|
||||||
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
@@ -14,41 +12,24 @@
|
|||||||
<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>
|
||||||
<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">
|
||||||
|
<button type="button" class="btn-secondary !px-3 !py-2" @click="toggleTheme">
|
||||||
|
<Sun v-if="darkMode" size="18" />
|
||||||
|
<Moon v-else size="18" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<template v-if="currentUser">
|
<template v-if="currentUser">
|
||||||
<span class="text-sm font-bold text-slate-700">
|
<span class="text-sm font-bold text-slate-700">{{ currentUser.name }}</span>
|
||||||
{{ currentUser.name }}
|
<span v-if="isSuperadmin" class="rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700">Superadmin</span>
|
||||||
</span>
|
<button class="btn-secondary !px-4 !py-2" @click="logout">Logout</button>
|
||||||
|
|
||||||
<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">
|
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">Login</RouterLink>
|
||||||
Login
|
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink>
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/register" class="btn-primary !px-4 !py-2">
|
|
||||||
Register
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,37 +45,26 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<RouterLink
|
<button type="button" class="btn-secondary mt-2 !py-2 text-center" @click="toggleTheme">
|
||||||
v-if="isSuperadmin"
|
<Sun v-if="darkMode" size="18" />
|
||||||
class="mobile-link"
|
<Moon v-else size="18" />
|
||||||
to="/admin/products"
|
{{ darkMode ? 'Light Mode' : 'Dark Mode' }}
|
||||||
@click="open = false"
|
</button>
|
||||||
>
|
|
||||||
Admin Marketplace
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<div v-if="currentUser" class="mt-2 grid gap-2">
|
<div v-if="currentUser" class="mt-2 grid gap-2">
|
||||||
<div class="rounded-xl bg-slate-50 px-3 py-2">
|
<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-sm font-bold text-slate-800">{{ currentUser.name }}</p>
|
||||||
<p class="text-xs text-slate-500">{{ currentUser.email }}</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">
|
<p v-if="isSuperadmin" class="mt-1 text-xs font-bold text-red-700">Superadmin</p>
|
||||||
Superadmin
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn-secondary !py-2 text-center" @click="logout">Logout</button>
|
||||||
<button class="btn-secondary !py-2 text-center" @click="logout">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
</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">
|
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open = false">Login</RouterLink>
|
||||||
Login
|
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">Register</RouterLink>
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">
|
|
||||||
Register
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,51 +72,60 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from "vue"
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { Cpu, Menu } from "lucide-vue-next"
|
import { Menu, Moon, Sun } from 'lucide-vue-next';
|
||||||
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
|
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from '../utils';
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false);
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const currentUser = ref(getCurrentUser())
|
const currentUser = ref(getCurrentUser());
|
||||||
|
const darkMode = ref(localStorage.getItem('secondtech_theme') === 'dark');
|
||||||
|
|
||||||
const isSuperadmin = computed(() => {
|
const isSuperadmin = computed(() => currentUser.value?.role === 'superadmin');
|
||||||
return currentUser.value?.role === "superadmin"
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
() => {
|
() => {
|
||||||
currentUser.value = getCurrentUser()
|
currentUser.value = getCurrentUser();
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
document.documentElement.classList.toggle('dark', darkMode.value);
|
||||||
|
localStorage.setItem('secondtech_theme', darkMode.value ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
darkMode.value = !darkMode.value;
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshCurrentUser() {
|
async function refreshCurrentUser() {
|
||||||
if (!getToken()) return
|
if (!getToken()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchCurrentUser()
|
const data = await fetchCurrentUser();
|
||||||
setAuth({
|
setAuth({ token: getToken(), user: data.user });
|
||||||
token: getToken(),
|
currentUser.value = getCurrentUser();
|
||||||
user: data.user
|
|
||||||
})
|
|
||||||
currentUser.value = getCurrentUser()
|
|
||||||
} catch {
|
} catch {
|
||||||
clearAuth()
|
clearAuth();
|
||||||
currentUser.value = null
|
currentUser.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearAuth()
|
clearAuth();
|
||||||
currentUser.value = null
|
currentUser.value = null;
|
||||||
open.value = false
|
open.value = false;
|
||||||
router.push("/login")
|
router.push('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(refreshCurrentUser)
|
onMounted(() => {
|
||||||
|
applyTheme();
|
||||||
|
refreshCurrentUser();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
+13
-98
@@ -1,101 +1,4 @@
|
|||||||
export const products = [
|
export const products = []
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "Laptop Lenovo ThinkPad T480",
|
|
||||||
category: "Laptop",
|
|
||||||
price: 3200000,
|
|
||||||
condition: "Bekas Normal",
|
|
||||||
location: "Yogyakarta",
|
|
||||||
seller: "Raka SIJA",
|
|
||||||
whatsapp: "6281234567890",
|
|
||||||
image: "https://images.unsplash.com/photo-1496181133206-80ce9b88a853?q=80&w=1200&auto=format&fit=crop",
|
|
||||||
description: "Laptop bekas cocok untuk belajar coding, jaringan, virtual machine ringan, dan kebutuhan sekolah. RAM 8GB, SSD 256GB, keyboard normal."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "PC Rakitan i5 Gen 8",
|
|
||||||
category: "PC",
|
|
||||||
price: 4100000,
|
|
||||||
condition: "Bekas Normal",
|
|
||||||
location: "Bantul",
|
|
||||||
seller: "Dimas Tech",
|
|
||||||
whatsapp: "628111222333",
|
|
||||||
image: "https://images.unsplash.com/photo-1587202372775-e229f172b9d7?q=80&w=1200&auto=format&fit=crop",
|
|
||||||
description: "PC rakitan untuk lab jaringan, desain ringan, dan multitasking. Intel Core i5, RAM 16GB, SSD 512GB."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Monitor LG 24 Inch IPS",
|
|
||||||
category: "Monitor",
|
|
||||||
price: 1150000,
|
|
||||||
condition: "Bekas Mulus",
|
|
||||||
location: "Sleman",
|
|
||||||
seller: "Adit Hardware",
|
|
||||||
whatsapp: "628555666777",
|
|
||||||
image: "https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?q=80&w=1200&auto=format&fit=crop",
|
|
||||||
description: "Monitor IPS 24 inch, warna masih bagus, cocok untuk coding dan editing. Include kabel power dan HDMI."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "Keyboard Mechanical Keychron K2",
|
|
||||||
category: "Keyboard",
|
|
||||||
price: 850000,
|
|
||||||
condition: "Bekas Normal",
|
|
||||||
location: "Solo",
|
|
||||||
seller: "Naufal Keys",
|
|
||||||
whatsapp: "628333444555",
|
|
||||||
image: "https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?q=80&w=1200&auto=format&fit=crop",
|
|
||||||
description: "Keyboard mechanical wireless, switch brown, cocok untuk coding. Kondisi normal dan keycap lengkap."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "MikroTik RB941 hAP Lite",
|
|
||||||
category: "Router",
|
|
||||||
price: 180000,
|
|
||||||
condition: "Bekas Normal",
|
|
||||||
location: "Kulon Progo",
|
|
||||||
seller: "Lab Network",
|
|
||||||
whatsapp: "628777888999",
|
|
||||||
image: "https://images.unsplash.com/photo-1606904825846-647eb07f5be2?q=80&w=1200&auto=format&fit=crop",
|
|
||||||
description: "Router MikroTik untuk belajar routing, firewall, DHCP, hotspot, dan konfigurasi dasar jaringan."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: "Switch TP-Link 8 Port Gigabit",
|
|
||||||
category: "Switch",
|
|
||||||
price: 250000,
|
|
||||||
condition: "Bekas Normal",
|
|
||||||
location: "Magelang",
|
|
||||||
seller: "Fajar Net",
|
|
||||||
whatsapp: "628999111222",
|
|
||||||
image: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop",
|
|
||||||
description: "Switch 8 port gigabit, cocok untuk lab kecil, warnet mini, dan praktik jaringan lokal."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
title: "Server Dell PowerEdge R620",
|
|
||||||
category: "Server",
|
|
||||||
price: 6500000,
|
|
||||||
condition: "Bekas Server Room",
|
|
||||||
location: "Jakarta",
|
|
||||||
seller: "Server Bekas ID",
|
|
||||||
whatsapp: "628222333444",
|
|
||||||
image: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop",
|
|
||||||
description: "Server rackmount bekas data center, cocok untuk belajar virtualization, Proxmox, Docker, dan homelab."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
title: "Access Point TP-Link EAP225",
|
|
||||||
category: "Access Point",
|
|
||||||
price: 620000,
|
|
||||||
condition: "Bekas Mulus",
|
|
||||||
location: "Semarang",
|
|
||||||
seller: "WiFi Store",
|
|
||||||
whatsapp: "6281212121212",
|
|
||||||
image: "https://images.unsplash.com/photo-1544197150-b99a580bb7a8?q=80&w=1200&auto=format&fit=crop",
|
|
||||||
description: "Access point ceiling untuk hotspot sekolah, kantor, dan lab jaringan. Kondisi normal."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export const categories = [
|
export const categories = [
|
||||||
"Semua",
|
"Semua",
|
||||||
@@ -108,3 +11,15 @@ export const categories = [
|
|||||||
"Server",
|
"Server",
|
||||||
"Access Point"
|
"Access Point"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const categoryOptions = [
|
||||||
|
{ name: "Semua", icon: "Boxes" },
|
||||||
|
{ name: "Laptop", icon: "Laptop" },
|
||||||
|
{ 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")
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ 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) {
|
||||||
|
|||||||
+94
-20
@@ -2,15 +2,36 @@
|
|||||||
<section class="bg-gradient-to-br from-brand-50 via-white to-slate-100">
|
<section class="bg-gradient-to-br from-brand-50 via-white to-slate-100">
|
||||||
<div class="container-page grid min-h-[560px] items-center gap-10 py-16 lg:grid-cols-2">
|
<div class="container-page grid min-h-[560px] items-center gap-10 py-16 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<span class="rounded-full bg-brand-100 px-4 py-2 text-sm font-bold text-brand-700">
|
<ShinyText
|
||||||
Marketplace Barang Bekas Teknologi
|
text="SecondTech"
|
||||||
</span>
|
className="text-1xl font-extrabold tracking-tight sm:text-1xl lg:text-2xl"
|
||||||
<h1 class="mt-6 text-4xl font-extrabold tracking-tight text-slate-950 sm:text-5xl lg:text-6xl">
|
:color="'#1c4ed9'"
|
||||||
Jual beli perangkat IT bekas jadi lebih mudah.
|
:shineColor="'#e0e7ff'"
|
||||||
</h1>
|
:spread="45"
|
||||||
|
:speed="1"
|
||||||
|
:delay="2"
|
||||||
|
:direction="'left'"
|
||||||
|
:pauseOnHover="true"
|
||||||
|
:yoyo="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<TextType
|
||||||
|
as="h2"
|
||||||
|
className="mt-6 text-3xl font-extrabold tracking-tight text-zinc-950 sm:text-6xl lg:text-5xl"
|
||||||
|
:text="['Menjual Barang Bekas, yang kamu butuhkan, di satu tempat.']"
|
||||||
|
:typingSpeed="75"
|
||||||
|
:pauseDuration="1500"
|
||||||
|
:showCursor="false"
|
||||||
|
:textColors="['var(--hero-title-color)']"
|
||||||
|
cursorCharacter="|"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
<RouterLink to="/marketplace" class="btn-secondary">Lihat Marketplace</RouterLink>
|
<RouterLink to="/marketplace" class="btn-secondary">Lihat Marketplace</RouterLink>
|
||||||
@@ -21,7 +42,7 @@
|
|||||||
<div class="absolute -inset-4 rounded-[2rem] bg-brand-100 blur-2xl"></div>
|
<div class="absolute -inset-4 rounded-[2rem] bg-brand-100 blur-2xl"></div>
|
||||||
<img
|
<img
|
||||||
class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl"
|
class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl"
|
||||||
src="https://images.unsplash.com/photo-1516321318423-f06f85e504b3?q=80&w=1200&auto=format&fit=crop"
|
src="/Hero.jpeg"
|
||||||
alt="Tech marketplace"
|
alt="Tech marketplace"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,15 +55,22 @@
|
|||||||
<h2 class="text-2xl font-extrabold">Kategori Populer</h2>
|
<h2 class="text-2xl font-extrabold">Kategori Populer</h2>
|
||||||
<p class="mt-2 text-slate-600">Fokus ke perangkat teknologi dan jaringan.</p>
|
<p class="mt-2 text-slate-600">Fokus ke perangkat teknologi dan jaringan.</p>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink to="/marketplace" class="text-sm font-bold text-brand-700">Lihat semua</RouterLink>
|
<RouterLink to="/marketplace" class="text-sm font-bold text-brand-700">
|
||||||
|
Lihat semua
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<div v-for="cat in categoryList" :key="cat" 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>
|
||||||
@@ -52,11 +80,11 @@
|
|||||||
<div class="mb-8 flex items-end justify-between gap-4">
|
<div class="mb-8 flex items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-extrabold">Produk Terbaru</h2>
|
<h2 class="text-2xl font-extrabold">Produk Terbaru</h2>
|
||||||
<p class="mt-2 text-slate-600">Contoh barang bekas teknologi yang tersedia.</p>
|
<p class="mt-2 text-slate-600">Barang terbaru yang tersedia di marketplace.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div v-if="latestProducts.length" class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<ProductCard
|
<ProductCard
|
||||||
v-for="product in latestProducts"
|
v-for="product in latestProducts"
|
||||||
:key="product.id"
|
:key="product.id"
|
||||||
@@ -65,20 +93,54 @@
|
|||||||
@toggle-save="toggleSave"
|
@toggle-save="toggleSave"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card p-10 text-center">
|
||||||
|
<h3 class="text-xl font-extrabold">Belum ada produk</h3>
|
||||||
|
<p class="mt-2 text-slate-600">Produk terbaru akan muncul setelah barang diposting.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from "vue"
|
import { computed, onMounted, ref } from "vue"
|
||||||
import { Cpu } from "lucide-vue-next"
|
import { Boxes, Keyboard, Laptop, Monitor, Network, PcCase, Router, Server, Wifi } from "lucide-vue-next"
|
||||||
import ProductCard from "../components/ProductCard.vue"
|
import ProductCard from "../components/ProductCard.vue"
|
||||||
import { products, categories } from "../data/products"
|
import { categoryOptions, products as defaultProducts } from "../data/products"
|
||||||
import { getSavedProducts, setSavedProducts } from "../utils"
|
import { fetchProducts, getSavedProducts, setSavedProducts } from "../utils"
|
||||||
|
import ShinyText from "../vuebits/ShinyText/ShinyText.vue"
|
||||||
|
import TextType from "../vuebits/TextType/TextType.vue"
|
||||||
|
|
||||||
const saved = ref(getSavedProducts())
|
const saved = ref(getSavedProducts())
|
||||||
const categoryList = categories.filter((c) => c !== "Semua").slice(0, 6)
|
const products = ref(defaultProducts)
|
||||||
const latestProducts = computed(() => products.slice(0, 4))
|
|
||||||
|
const categoryList = categoryOptions
|
||||||
|
.filter((category) => category.name !== "Semua")
|
||||||
|
.slice(0, 6)
|
||||||
|
|
||||||
|
const latestProducts = computed(() => {
|
||||||
|
return [...products.value]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.created_at || 0).getTime()
|
||||||
|
const dateB = new Date(b.created_at || 0).getTime()
|
||||||
|
|
||||||
|
if (dateA !== dateB) return dateB - dateA
|
||||||
|
return Number(b.id) - Number(a.id)
|
||||||
|
})
|
||||||
|
.slice(0, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
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)) {
|
||||||
@@ -86,6 +148,18 @@ function toggleSave(id) {
|
|||||||
} else {
|
} else {
|
||||||
saved.value.push(id)
|
saved.value.push(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
setSavedProducts(saved.value)
|
setSavedProducts(saved.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLatestProducts() {
|
||||||
|
try {
|
||||||
|
const data = await fetchProducts()
|
||||||
|
products.value = data.products?.length ? data.products : defaultProducts
|
||||||
|
} catch {
|
||||||
|
products.value = defaultProducts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadLatestProducts)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -17,6 +17,10 @@
|
|||||||
<input v-model="password" type="password" class="input mt-2" placeholder="••••••••" required />
|
<input v-model="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 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">
|
||||||
|
<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>
|
||||||
{{ loading ? "Memproses..." : "Masuk" }}
|
{{ loading ? "Memproses..." : "Masuk" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -8,31 +8,63 @@
|
|||||||
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
|
<h1 class="text-2xl font-extrabold">Memuat produk...</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="product" class="grid gap-8 lg:grid-cols-2">
|
<div v-else-if="product" class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(380px,520px)]">
|
||||||
<div class="card overflow-hidden">
|
<div class="grid items-start gap-4 sm:grid-cols-[88px_minmax(0,1fr)]">
|
||||||
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
|
<div class="order-2 flex gap-3 overflow-x-auto sm:order-1 sm:grid sm:content-start sm:max-h-[78vh] sm:overflow-y-auto">
|
||||||
|
<button
|
||||||
|
v-for="(image, index) in productImages"
|
||||||
|
:key="`${image}-${index}`"
|
||||||
|
type="button"
|
||||||
|
class="h-20 w-20 shrink-0 overflow-hidden rounded-xl border-2 bg-white"
|
||||||
|
:class="selectedImage === image ? 'border-brand-600' : 'border-slate-200'"
|
||||||
|
@click="selectedImage = image"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="getProductImageUrl(image)"
|
||||||
|
:alt="`${product.title} ${index + 1}`"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-1 sm:order-2">
|
||||||
|
<img
|
||||||
|
:src="getProductImageUrl(selectedImage)"
|
||||||
|
:alt="product.title"
|
||||||
|
class="block h-auto max-h-[78vh] w-auto max-w-full rounded-2xl object-contain shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="rounded-full bg-brand-50 px-3 py-1 text-sm font-bold text-brand-700">
|
<span class="rounded-full bg-brand-50 px-3 py-1 text-sm font-bold text-brand-700">
|
||||||
{{ product.category }}
|
{{ product.category }}
|
||||||
</span>
|
</span>
|
||||||
<h1 class="mt-4 text-3xl font-extrabold text-slate-950">{{ product.title }}</h1>
|
|
||||||
<p class="mt-3 text-3xl font-extrabold text-brand-700">{{ formatRupiah(product.price) }}</p>
|
<h1 class="mt-4 text-3xl font-extrabold text-slate-950">
|
||||||
|
{{ product.title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-3 text-3xl font-extrabold text-brand-700">
|
||||||
|
{{ formatRupiah(product.price) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Kondisi</p>
|
<p class="text-xs font-bold uppercase text-slate-500">Kondisi</p>
|
||||||
<p class="mt-1 font-bold">{{ product.condition }}</p>
|
<p class="mt-1 font-bold">{{ product.condition }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Lokasi</p>
|
<p class="text-xs font-bold uppercase text-slate-500">Lokasi</p>
|
||||||
<p class="mt-1 font-bold">{{ product.location }}</p>
|
<p class="mt-1 font-bold">{{ product.location }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">Penjual</p>
|
<p class="text-xs font-bold uppercase text-slate-500">Penjual</p>
|
||||||
<p class="mt-1 font-bold">{{ product.seller }}</p>
|
<p class="mt-1 font-bold">{{ product.seller }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<p class="text-xs font-bold uppercase text-slate-500">WhatsApp</p>
|
<p class="text-xs font-bold uppercase text-slate-500">WhatsApp</p>
|
||||||
<p class="mt-1 font-bold">{{ product.whatsapp }}</p>
|
<p class="mt-1 font-bold">{{ product.whatsapp }}</p>
|
||||||
@@ -41,11 +73,16 @@
|
|||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
<h2 class="text-lg font-extrabold">Deskripsi</h2>
|
||||||
<p class="mt-2 leading-7 text-slate-600">{{ product.description }}</p>
|
<p class="mt-2 leading-7 text-slate-600">
|
||||||
|
{{ product.description }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap gap-3">
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
<a :href="waLink" target="_blank" class="btn-primary">Hubungi via WhatsApp</a>
|
<a :href="waLink" target="_blank" class="btn-primary">
|
||||||
|
Hubungi via WhatsApp
|
||||||
|
</a>
|
||||||
|
|
||||||
<button class="btn-secondary" @click="toggleSave">
|
<button class="btn-secondary" @click="toggleSave">
|
||||||
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
|
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
|
||||||
</button>
|
</button>
|
||||||
@@ -55,28 +92,65 @@
|
|||||||
|
|
||||||
<div v-else class="card p-10 text-center">
|
<div v-else class="card p-10 text-center">
|
||||||
<h1 class="text-2xl font-extrabold">Produk tidak ditemukan</h1>
|
<h1 class="text-2xl font-extrabold">Produk tidak ditemukan</h1>
|
||||||
<RouterLink to="/marketplace" class="btn-primary mt-5">Lihat Marketplace</RouterLink>
|
<RouterLink to="/marketplace" class="btn-primary mt-5">
|
||||||
|
Lihat Marketplace
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from "vue"
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
import { useRoute } from "vue-router"
|
import { useRoute } from "vue-router"
|
||||||
import { products as defaultProducts } from "../data/products"
|
import { products as defaultProducts } from "../data/products"
|
||||||
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
|
import {
|
||||||
|
fetchProduct,
|
||||||
|
formatRupiah,
|
||||||
|
getProductImageUrl,
|
||||||
|
getSavedProducts,
|
||||||
|
setSavedProducts
|
||||||
|
} from "../utils"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const id = Number(route.params.id)
|
const id = Number(route.params.id)
|
||||||
|
|
||||||
const product = ref(null)
|
const product = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const selectedImage = ref("")
|
||||||
|
|
||||||
|
const productImages = computed(() => {
|
||||||
|
if (!product.value) return []
|
||||||
|
|
||||||
|
const images = product.value.images?.length
|
||||||
|
? product.value.images
|
||||||
|
: [product.value.image]
|
||||||
|
|
||||||
|
return [...new Set(images.filter(Boolean))]
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
productImages,
|
||||||
|
(images) => {
|
||||||
|
if (!images.includes(selectedImage.value)) {
|
||||||
|
selectedImage.value = images[0] || ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const savedIds = ref(getSavedProducts())
|
const savedIds = ref(getSavedProducts())
|
||||||
const saved = computed(() => savedIds.value.includes(id))
|
|
||||||
|
const saved = computed(() => {
|
||||||
|
return savedIds.value.includes(id)
|
||||||
|
})
|
||||||
|
|
||||||
const waLink = computed(() => {
|
const waLink = computed(() => {
|
||||||
if (!product.value) return "#"
|
if (!product.value) return "#"
|
||||||
const text = encodeURIComponent(`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`)
|
|
||||||
|
const text = encodeURIComponent(
|
||||||
|
`Halo, saya tertarik dengan ${product.value.title} di SecondTech Market.`
|
||||||
|
)
|
||||||
|
|
||||||
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
return `https://wa.me/${product.value.whatsapp}?text=${text}`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -86,16 +160,22 @@ function toggleSave() {
|
|||||||
} else {
|
} else {
|
||||||
savedIds.value.push(id)
|
savedIds.value.push(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
setSavedProducts(savedIds.value)
|
setSavedProducts(savedIds.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProduct() {
|
async function loadProduct() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchProduct(id)
|
const data = await fetchProduct(id)
|
||||||
product.value = data.product
|
product.value = data.product
|
||||||
} catch {
|
} catch {
|
||||||
product.value = defaultProducts.find((item) => Number(item.id) === id) || null
|
const fallbackProduct = defaultProducts.find((item) => Number(item.id) === id) || null
|
||||||
|
|
||||||
|
product.value = fallbackProduct
|
||||||
|
? { ...fallbackProduct, images: [fallbackProduct.image] }
|
||||||
|
: null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
<input v-model="form.password" type="password" class="input mt-2" placeholder="••••••••" required />
|
<input 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 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">
|
||||||
|
<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>
|
||||||
{{ loading ? "Memproses..." : "Daftar" }}
|
{{ loading ? "Memproses..." : "Daftar" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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 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]">
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<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">
|
<select v-model="form.category" class="input mt-2" required>
|
||||||
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
|
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,9 +58,32 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="label">Foto Barang</label>
|
<label class="label">Foto Barang</label>
|
||||||
<input type="file" accept="image/*" class="input mt-2" required @change="handleImageChange" />
|
<input
|
||||||
<img v-if="previewUrl" :src="previewUrl" alt="Preview foto barang" class="mt-4 aspect-[4/3] w-full max-w-sm rounded-xl object-cover" />
|
ref="fileInput"
|
||||||
<p class="mt-2 text-xs text-slate-500">Upload JPG, PNG, atau WebP. Maksimal 3MB.</p>
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
class="input mt-2"
|
||||||
|
@change="handleImageChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<span>{{ selectedImages.length }} foto dipilih</span>
|
||||||
|
<button type="button" class="text-red-700" @click="clearImages">Hapus semua</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="previewUrls.length" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-xs text-slate-500">
|
||||||
|
Maksimal 6 foto, 10 MB per foto. Kamu boleh pilih beberapa sekaligus atau tambah satu per satu.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -76,10 +99,10 @@
|
|||||||
<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>• Pakai judul yang jelas, misal “MikroTik RB941 Bekas Normal”.</li>
|
<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>• Pakai foto yang terang dan jelas.</li>
|
<li>- Upload beberapa foto dari sisi yang berbeda.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,6 +117,10 @@ import { createProduct, getCurrentUser } from "../utils"
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const realCategories = computed(() => categories.filter((cat) => cat !== "Semua"))
|
const realCategories = computed(() => categories.filter((cat) => cat !== "Semua"))
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const MAX_IMAGES = 6
|
||||||
|
const MAX_IMAGE_SIZE_MB = 10
|
||||||
|
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
title: "",
|
title: "",
|
||||||
@@ -105,8 +132,8 @@ const form = reactive({
|
|||||||
whatsapp: "",
|
whatsapp: "",
|
||||||
description: ""
|
description: ""
|
||||||
})
|
})
|
||||||
const selectedImage = ref(null)
|
const selectedImages = ref([])
|
||||||
const previewUrl = ref("")
|
const previewUrls = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref("")
|
const errorMessage = ref("")
|
||||||
|
|
||||||
@@ -116,15 +143,55 @@ if (currentUser) {
|
|||||||
form.whatsapp = currentUser.whatsapp || ""
|
form.whatsapp = currentUser.whatsapp || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageKey(file) {
|
||||||
|
return `${file.name}-${file.size}-${file.lastModified}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildPreviews() {
|
||||||
|
previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url))
|
||||||
|
previewUrls.value = selectedImages.value.map((file) => ({
|
||||||
|
key: imageKey(file),
|
||||||
|
url: URL.createObjectURL(file)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function handleImageChange(event) {
|
function handleImageChange(event) {
|
||||||
const file = event.target.files?.[0]
|
const incomingFiles = Array.from(event.target.files || [])
|
||||||
selectedImage.value = file || null
|
const oversizedFile = incomingFiles.find((file) => file.size > MAX_IMAGE_SIZE)
|
||||||
previewUrl.value = file ? URL.createObjectURL(file) : ""
|
|
||||||
|
if (oversizedFile) {
|
||||||
|
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`
|
||||||
|
event.target.value = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = [...selectedImages.value]
|
||||||
|
incomingFiles.forEach((file) => {
|
||||||
|
if (!merged.some((item) => imageKey(item) === imageKey(file)) && merged.length < MAX_IMAGES) {
|
||||||
|
merged.push(file)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
selectedImages.value = merged
|
||||||
|
rebuildPreviews()
|
||||||
|
errorMessage.value = ""
|
||||||
|
event.target.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(index) {
|
||||||
|
selectedImages.value = selectedImages.value.filter((_, itemIndex) => itemIndex !== index)
|
||||||
|
rebuildPreviews()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImages() {
|
||||||
|
selectedImages.value = []
|
||||||
|
rebuildPreviews()
|
||||||
|
if (fileInput.value) fileInput.value.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitProduct() {
|
async function submitProduct() {
|
||||||
if (!selectedImage.value) {
|
if (!selectedImages.value.length) {
|
||||||
errorMessage.value = "Foto barang wajib diupload."
|
errorMessage.value = "Minimal 1 foto barang wajib diupload."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +202,9 @@ async function submitProduct() {
|
|||||||
Object.entries(form).forEach(([key, value]) => {
|
Object.entries(form).forEach(([key, value]) => {
|
||||||
payload.append(key, value)
|
payload.append(key, value)
|
||||||
})
|
})
|
||||||
payload.append("image", selectedImage.value)
|
selectedImages.value.forEach((image) => {
|
||||||
|
payload.append("images", image)
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createProduct(payload)
|
await createProduct(payload)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: "class",
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{vue,js}"
|
"./src/**/*.{vue,js}"
|
||||||
@@ -23,3 +24,4 @@ export default {
|
|||||||
},
|
},
|
||||||
plugins: []
|
plugins: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user