Initial commit - Web Javaherbal dengan auth backend
This commit is contained in:
commit
6e2c3382ac
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
build
|
||||||
|
.vercel
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
# 🚀 Deploy Web Javaherbal ke Vercel
|
||||||
|
|
||||||
|
## ✅ Persiapan Selesai!
|
||||||
|
|
||||||
|
File-file yang sudah disiapkan:
|
||||||
|
- ✅ `.gitignore` - Exclude node_modules, dist, .env
|
||||||
|
- ✅ `vercel.json` - Konfigurasi Vercel
|
||||||
|
- ✅ `package.json` - Script build sudah ada
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 LANGKAH-LANGKAH DEPLOY
|
||||||
|
|
||||||
|
### 1️⃣ Push ke GitHub
|
||||||
|
|
||||||
|
#### A. Buat Repository Baru di GitHub
|
||||||
|
1. Buka https://github.com
|
||||||
|
2. Klik tombol **"New"** (repository baru)
|
||||||
|
3. Isi:
|
||||||
|
- Repository name: `frondend-web`
|
||||||
|
- Description: `Web dashboard untuk sistem pengeringan cabai jawa`
|
||||||
|
- Visibility: **Public** atau **Private**
|
||||||
|
4. **JANGAN** centang "Add a README file"
|
||||||
|
5. Klik **"Create repository"**
|
||||||
|
|
||||||
|
#### B. Push Code ke GitHub
|
||||||
|
```bash
|
||||||
|
cd javaherbal-v2/javaherbal2
|
||||||
|
|
||||||
|
# Initialize git
|
||||||
|
git init
|
||||||
|
|
||||||
|
# Add all files
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
git commit -m "Initial commit - Web Javaherbal"
|
||||||
|
|
||||||
|
# Add remote
|
||||||
|
git remote add origin https://github.com/flemss/frondend-web.git
|
||||||
|
|
||||||
|
# Push
|
||||||
|
git branch -M main
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ Deploy ke Vercel
|
||||||
|
|
||||||
|
#### A. Buka Vercel
|
||||||
|
1. Buka https://vercel.com
|
||||||
|
2. Klik **"Sign Up"** atau **"Login"**
|
||||||
|
3. Login dengan **GitHub**
|
||||||
|
|
||||||
|
#### B. Import Project
|
||||||
|
1. Klik **"Add New..."** → **"Project"**
|
||||||
|
2. Pilih **"Import Git Repository"**
|
||||||
|
3. Cari repository **"frondend-web"**
|
||||||
|
4. Klik **"Import"**
|
||||||
|
|
||||||
|
#### C. Configure Project
|
||||||
|
1. **Project Name:** `frondend-web` (atau nama lain)
|
||||||
|
2. **Framework Preset:** Vercel akan auto-detect **Vite**
|
||||||
|
3. **Root Directory:** `./` (default)
|
||||||
|
4. **Build Command:** `npm run build` (auto-filled)
|
||||||
|
5. **Output Directory:** `dist` (auto-filled)
|
||||||
|
6. **Install Command:** `npm install` (auto-filled)
|
||||||
|
|
||||||
|
#### D. Environment Variables (Opsional)
|
||||||
|
Jika ada environment variables, tambahkan di sini.
|
||||||
|
Untuk saat ini, tidak perlu karena API_URL sudah hardcoded.
|
||||||
|
|
||||||
|
#### E. Deploy!
|
||||||
|
1. Klik **"Deploy"**
|
||||||
|
2. Tunggu 1-2 menit
|
||||||
|
3. **Selesai!** 🎉
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 URL VERCEL
|
||||||
|
|
||||||
|
Setelah deploy selesai, Vercel akan memberikan URL seperti:
|
||||||
|
```
|
||||||
|
https://frondend-web.vercel.app
|
||||||
|
```
|
||||||
|
|
||||||
|
atau
|
||||||
|
|
||||||
|
```
|
||||||
|
https://frondend-web-flemss.vercel.app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 TESTING
|
||||||
|
|
||||||
|
### 1. Buka URL Vercel
|
||||||
|
```
|
||||||
|
https://frondend-web.vercel.app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Register
|
||||||
|
1. Klik "Daftar di sini"
|
||||||
|
2. Isi form registrasi
|
||||||
|
3. Klik "Daftar"
|
||||||
|
4. **Expected:** Alert "Registrasi berhasil!" → Redirect ke login
|
||||||
|
|
||||||
|
### 3. Test Login
|
||||||
|
1. Isi form login
|
||||||
|
2. Klik "Masuk"
|
||||||
|
3. **Expected:** Redirect ke beranda
|
||||||
|
4. **Expected:** Floating stat suhu muncul
|
||||||
|
|
||||||
|
### 4. Test Control Panel
|
||||||
|
1. Klik menu "Control"
|
||||||
|
2. **Expected:** Panel kontrol muncul
|
||||||
|
3. **Expected:** Data suhu real-time
|
||||||
|
4. **Expected:** Toggle heater berfungsi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 UPDATE WEB
|
||||||
|
|
||||||
|
Setiap kali ada perubahan code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd javaherbal-v2/javaherbal2
|
||||||
|
|
||||||
|
# Add changes
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
git commit -m "Update: deskripsi perubahan"
|
||||||
|
|
||||||
|
# Push
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vercel akan otomatis:**
|
||||||
|
- Detect perubahan di GitHub
|
||||||
|
- Build ulang
|
||||||
|
- Deploy versi baru
|
||||||
|
- Zero downtime!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ CUSTOM DOMAIN (Opsional)
|
||||||
|
|
||||||
|
Jika ingin pakai domain sendiri (contoh: `javaherbal.com`):
|
||||||
|
|
||||||
|
### 1. Beli Domain
|
||||||
|
- Namecheap, GoDaddy, Niagahoster, dll
|
||||||
|
|
||||||
|
### 2. Tambah Domain di Vercel
|
||||||
|
1. Buka project di Vercel dashboard
|
||||||
|
2. Klik tab **"Settings"**
|
||||||
|
3. Klik **"Domains"**
|
||||||
|
4. Klik **"Add"**
|
||||||
|
5. Masukkan domain Anda
|
||||||
|
6. Follow instruksi untuk update DNS
|
||||||
|
|
||||||
|
### 3. Update DNS
|
||||||
|
Di provider domain Anda, tambahkan record:
|
||||||
|
```
|
||||||
|
Type: CNAME
|
||||||
|
Name: www
|
||||||
|
Value: cname.vercel-dns.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Tunggu 5-60 menit untuk propagasi DNS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 ENVIRONMENT VARIABLES (Jika Diperlukan)
|
||||||
|
|
||||||
|
Jika ingin API_URL bisa diubah tanpa rebuild:
|
||||||
|
|
||||||
|
### 1. Buat file `.env.production`
|
||||||
|
```bash
|
||||||
|
VITE_API_URL=https://web-production-32384.up.railway.app/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Update code untuk pakai env variable
|
||||||
|
```typescript
|
||||||
|
// Sebelum
|
||||||
|
const API_URL = 'https://web-production-32384.up.railway.app/api'
|
||||||
|
|
||||||
|
// Sesudah
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'https://web-production-32384.up.railway.app/api'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Set di Vercel Dashboard
|
||||||
|
1. Buka project di Vercel
|
||||||
|
2. Klik tab **"Settings"**
|
||||||
|
3. Klik **"Environment Variables"**
|
||||||
|
4. Tambah:
|
||||||
|
- Key: `VITE_API_URL`
|
||||||
|
- Value: `https://web-production-32384.up.railway.app/api`
|
||||||
|
5. Save
|
||||||
|
|
||||||
|
### 4. Redeploy
|
||||||
|
Vercel akan auto redeploy dengan env variable baru.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 MONITORING
|
||||||
|
|
||||||
|
### Vercel Dashboard
|
||||||
|
https://vercel.com/dashboard
|
||||||
|
|
||||||
|
**Fitur:**
|
||||||
|
- ✅ Deployment history
|
||||||
|
- ✅ Build logs
|
||||||
|
- ✅ Analytics (traffic, performance)
|
||||||
|
- ✅ Error tracking
|
||||||
|
- ✅ Custom domains
|
||||||
|
- ✅ Environment variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 TROUBLESHOOTING
|
||||||
|
|
||||||
|
### Problem: Build Failed
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
1. Cek build logs di Vercel dashboard
|
||||||
|
2. Test build lokal:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
3. Fix error yang muncul
|
||||||
|
4. Push lagi ke GitHub
|
||||||
|
|
||||||
|
### Problem: 404 Not Found
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
1. Pastikan `vercel.json` ada dan benar
|
||||||
|
2. Pastikan rewrites sudah diset untuk SPA routing
|
||||||
|
3. Redeploy
|
||||||
|
|
||||||
|
### Problem: API Not Working
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
1. Cek backend Railway masih online
|
||||||
|
2. Cek CORS di backend
|
||||||
|
3. Cek API_URL di code
|
||||||
|
4. Cek browser console untuk error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ CHECKLIST
|
||||||
|
|
||||||
|
### Sebelum Deploy:
|
||||||
|
- [x] `.gitignore` sudah dibuat
|
||||||
|
- [x] `vercel.json` sudah dibuat
|
||||||
|
- [x] `package.json` script build ada
|
||||||
|
- [x] API_URL sudah diset ke Railway
|
||||||
|
- [ ] Test build lokal (`npm run build`)
|
||||||
|
- [ ] Push ke GitHub
|
||||||
|
|
||||||
|
### Setelah Deploy:
|
||||||
|
- [ ] URL Vercel bisa diakses
|
||||||
|
- [ ] Register berfungsi
|
||||||
|
- [ ] Login berfungsi
|
||||||
|
- [ ] Control panel berfungsi
|
||||||
|
- [ ] Data real-time muncul
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 SELESAI!
|
||||||
|
|
||||||
|
Web Javaherbal sekarang online di Vercel!
|
||||||
|
|
||||||
|
**Keuntungan:**
|
||||||
|
- ✅ Gratis unlimited
|
||||||
|
- ✅ Auto deploy dari GitHub
|
||||||
|
- ✅ HTTPS otomatis
|
||||||
|
- ✅ CDN global (cepat)
|
||||||
|
- ✅ Zero downtime deployment
|
||||||
|
- ✅ Custom domain support
|
||||||
|
|
||||||
|
**Selamat! Web Anda sekarang production-ready!** 🚀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 SUPPORT
|
||||||
|
|
||||||
|
### Vercel Documentation
|
||||||
|
https://vercel.com/docs
|
||||||
|
|
||||||
|
### Vite Documentation
|
||||||
|
https://vitejs.dev/guide/
|
||||||
|
|
||||||
|
### React Router Documentation
|
||||||
|
https://reactrouter.com/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Created:** May 21, 2026
|
||||||
|
**Status:** ✅ Ready to Deploy
|
||||||
|
|
@ -0,0 +1,397 @@
|
||||||
|
# 📚 Penjelasan Sistem Login Web Javaherbal
|
||||||
|
|
||||||
|
## 🔐 Sistem Penyimpanan Akun
|
||||||
|
|
||||||
|
Web Javaherbal menggunakan **localStorage** browser untuk menyimpan data akun dan status login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Apa itu localStorage?
|
||||||
|
|
||||||
|
**localStorage** adalah fitur browser yang memungkinkan website menyimpan data di komputer user secara permanen (tidak hilang saat browser ditutup).
|
||||||
|
|
||||||
|
### Karakteristik localStorage:
|
||||||
|
- ✅ Data disimpan di **browser user** (bukan di server)
|
||||||
|
- ✅ Data **tidak hilang** saat browser ditutup
|
||||||
|
- ✅ Data **tetap ada** sampai dihapus manual
|
||||||
|
- ✅ Kapasitas: ~5-10 MB per domain
|
||||||
|
- ✅ Format: Key-Value (string)
|
||||||
|
- ❌ **TIDAK AMAN** untuk data sensitif (password terlihat plain text)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Alur Sistem Login
|
||||||
|
|
||||||
|
### 1️⃣ REGISTRASI (Register)
|
||||||
|
|
||||||
|
**File:** `src/pages/Register.tsx`
|
||||||
|
|
||||||
|
#### Langkah-langkah:
|
||||||
|
1. User mengisi form:
|
||||||
|
- Nama lengkap
|
||||||
|
- Email
|
||||||
|
- Password (minimal 6 karakter)
|
||||||
|
- Konfirmasi password
|
||||||
|
|
||||||
|
2. Validasi:
|
||||||
|
```typescript
|
||||||
|
// Cek password cocok
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Password tidak cocok')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cek panjang password
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('Password minimal 6 karakter')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cek email sudah terdaftar
|
||||||
|
const users = JSON.parse(localStorage.getItem('users') || '[]')
|
||||||
|
if (users.find((u: any) => u.email === email)) {
|
||||||
|
setError('Email sudah terdaftar')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Simpan ke localStorage:
|
||||||
|
```typescript
|
||||||
|
// Buat user baru
|
||||||
|
const newUser = { name, email, password }
|
||||||
|
|
||||||
|
// Ambil array users yang sudah ada
|
||||||
|
const users = JSON.parse(localStorage.getItem('users') || '[]')
|
||||||
|
|
||||||
|
// Tambahkan user baru
|
||||||
|
users.push(newUser)
|
||||||
|
|
||||||
|
// Simpan kembali ke localStorage
|
||||||
|
localStorage.setItem('users', JSON.stringify(users))
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Redirect ke halaman login
|
||||||
|
|
||||||
|
#### Contoh Data di localStorage:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"password": "123456"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jane Smith",
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ LOGIN
|
||||||
|
|
||||||
|
**File:** `src/pages/Login.tsx`
|
||||||
|
|
||||||
|
#### Langkah-langkah:
|
||||||
|
1. User mengisi form:
|
||||||
|
- Email
|
||||||
|
- Password
|
||||||
|
|
||||||
|
2. Validasi:
|
||||||
|
```typescript
|
||||||
|
// Ambil semua users dari localStorage
|
||||||
|
const users = JSON.parse(localStorage.getItem('users') || '[]')
|
||||||
|
|
||||||
|
// Cari user dengan email dan password yang cocok
|
||||||
|
const user = users.find((u: any) =>
|
||||||
|
u.email === email && u.password === password
|
||||||
|
)
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Login berhasil
|
||||||
|
localStorage.setItem('isAuthenticated', 'true')
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(user))
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
// Login gagal
|
||||||
|
setError('Email atau password salah')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Jika berhasil:
|
||||||
|
- Set `isAuthenticated` = `'true'`
|
||||||
|
- Simpan data user ke `currentUser`
|
||||||
|
- Redirect ke halaman beranda
|
||||||
|
|
||||||
|
#### Data di localStorage Setelah Login:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"users": [...],
|
||||||
|
"isAuthenticated": "true",
|
||||||
|
"currentUser": {
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"password": "123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ CEK STATUS LOGIN
|
||||||
|
|
||||||
|
**File:** `src/components/ProtectedRoute.tsx`
|
||||||
|
|
||||||
|
#### Cara Kerja:
|
||||||
|
```typescript
|
||||||
|
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true'
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Redirect ke login
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tampilkan halaman yang dilindungi
|
||||||
|
return <>{children}</>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Penggunaan:
|
||||||
|
```tsx
|
||||||
|
// Halaman yang perlu login
|
||||||
|
<Route
|
||||||
|
path="/control"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Control />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ LOGOUT
|
||||||
|
|
||||||
|
**File:** `src/components/Navbar.tsx` (kemungkinan)
|
||||||
|
|
||||||
|
#### Cara Kerja:
|
||||||
|
```typescript
|
||||||
|
function handleLogout() {
|
||||||
|
// Hapus status login
|
||||||
|
localStorage.removeItem('isAuthenticated')
|
||||||
|
localStorage.removeItem('currentUser')
|
||||||
|
|
||||||
|
// Redirect ke login
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Struktur Data di localStorage
|
||||||
|
|
||||||
|
### Key-Value Pairs:
|
||||||
|
|
||||||
|
| Key | Value | Deskripsi |
|
||||||
|
|-----|-------|-----------|
|
||||||
|
| `users` | `[{name, email, password}, ...]` | Array semua user yang terdaftar |
|
||||||
|
| `isAuthenticated` | `'true'` atau `'false'` | Status login user saat ini |
|
||||||
|
| `currentUser` | `{name, email, password}` | Data user yang sedang login |
|
||||||
|
|
||||||
|
### Contoh Lengkap:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"name": "Admin",
|
||||||
|
"email": "admin@javaherbal.com",
|
||||||
|
"password": "admin123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "User Test",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "test123"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isAuthenticated": "true",
|
||||||
|
"currentUser": {
|
||||||
|
"name": "Admin",
|
||||||
|
"email": "admin@javaherbal.com",
|
||||||
|
"password": "admin123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Cara Lihat Data di Browser
|
||||||
|
|
||||||
|
### Chrome / Edge:
|
||||||
|
1. Buka web di browser
|
||||||
|
2. Tekan **F12** (Developer Tools)
|
||||||
|
3. Klik tab **Application**
|
||||||
|
4. Di sidebar kiri, klik **Local Storage**
|
||||||
|
5. Pilih domain web (contoh: `http://localhost:5173`)
|
||||||
|
6. Lihat semua key-value yang tersimpan
|
||||||
|
|
||||||
|
### Firefox:
|
||||||
|
1. Buka web di browser
|
||||||
|
2. Tekan **F12** (Developer Tools)
|
||||||
|
3. Klik tab **Storage**
|
||||||
|
4. Di sidebar kiri, klik **Local Storage**
|
||||||
|
5. Pilih domain web
|
||||||
|
6. Lihat semua key-value yang tersimpan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ KELEMAHAN SISTEM INI
|
||||||
|
|
||||||
|
### 1. **Password Tidak Terenkripsi**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"password": "123456" // ❌ Plain text, bisa dilihat siapa saja!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
- Gunakan hashing (bcrypt, SHA-256)
|
||||||
|
- Simpan password di backend, bukan localStorage
|
||||||
|
|
||||||
|
### 2. **Data Bisa Dihapus User**
|
||||||
|
User bisa hapus localStorage via Developer Tools → semua akun hilang!
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
- Simpan data di backend (database)
|
||||||
|
- localStorage hanya untuk token/session
|
||||||
|
|
||||||
|
### 3. **Tidak Aman untuk Production**
|
||||||
|
localStorage bisa diakses oleh JavaScript → rentan XSS attack
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
- Gunakan backend API untuk autentikasi
|
||||||
|
- Simpan token di httpOnly cookie
|
||||||
|
- Implementasi JWT (JSON Web Token)
|
||||||
|
|
||||||
|
### 4. **Data Lokal per Browser**
|
||||||
|
Akun di Chrome tidak bisa diakses di Firefox (data terpisah)
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
- Gunakan backend database (MongoDB, PostgreSQL)
|
||||||
|
- Sinkronisasi data via API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 REKOMENDASI UNTUK PRODUCTION
|
||||||
|
|
||||||
|
### Sistem Login yang Aman:
|
||||||
|
|
||||||
|
#### 1. **Backend API**
|
||||||
|
```
|
||||||
|
Frontend (React) → Backend API (Express) → Database (MongoDB)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. **Registrasi:**
|
||||||
|
```typescript
|
||||||
|
// Frontend
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, email, password })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Backend
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10)
|
||||||
|
await User.create({ name, email, password: hashedPassword })
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. **Login:**
|
||||||
|
```typescript
|
||||||
|
// Frontend
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
})
|
||||||
|
const { token } = await response.json()
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
|
||||||
|
// Backend
|
||||||
|
const user = await User.findOne({ email })
|
||||||
|
const isValid = await bcrypt.compare(password, user.password)
|
||||||
|
if (isValid) {
|
||||||
|
const token = jwt.sign({ userId: user._id }, SECRET_KEY)
|
||||||
|
return { token }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. **Protected Routes:**
|
||||||
|
```typescript
|
||||||
|
// Frontend
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const response = await fetch('/api/protected', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Backend
|
||||||
|
const token = req.headers.authorization?.split(' ')[1]
|
||||||
|
const decoded = jwt.verify(token, SECRET_KEY)
|
||||||
|
const user = await User.findById(decoded.userId)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 KESIMPULAN
|
||||||
|
|
||||||
|
### Sistem Saat Ini (localStorage):
|
||||||
|
- ✅ **Kelebihan:**
|
||||||
|
- Mudah implementasi
|
||||||
|
- Tidak perlu backend
|
||||||
|
- Cocok untuk prototype/demo
|
||||||
|
- Cepat
|
||||||
|
|
||||||
|
- ❌ **Kekurangan:**
|
||||||
|
- Tidak aman (password plain text)
|
||||||
|
- Data lokal (tidak sinkron antar device)
|
||||||
|
- Bisa dihapus user
|
||||||
|
- Tidak cocok untuk production
|
||||||
|
|
||||||
|
### Untuk Production:
|
||||||
|
- ✅ Gunakan backend API (Express + MongoDB)
|
||||||
|
- ✅ Hash password (bcrypt)
|
||||||
|
- ✅ Gunakan JWT untuk token
|
||||||
|
- ✅ Simpan token di httpOnly cookie
|
||||||
|
- ✅ Implementasi refresh token
|
||||||
|
- ✅ Rate limiting untuk prevent brute force
|
||||||
|
- ✅ HTTPS untuk enkripsi data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 NEXT STEPS
|
||||||
|
|
||||||
|
Jika ingin upgrade ke sistem yang lebih aman:
|
||||||
|
|
||||||
|
1. **Buat Backend API untuk Auth**
|
||||||
|
- Endpoint: `/api/auth/register`
|
||||||
|
- Endpoint: `/api/auth/login`
|
||||||
|
- Endpoint: `/api/auth/logout`
|
||||||
|
- Endpoint: `/api/auth/me` (get current user)
|
||||||
|
|
||||||
|
2. **Gunakan MongoDB untuk Simpan User**
|
||||||
|
- Collection: `users`
|
||||||
|
- Schema: `{ name, email, password (hashed), createdAt }`
|
||||||
|
|
||||||
|
3. **Implementasi JWT**
|
||||||
|
- Generate token saat login
|
||||||
|
- Verify token di protected routes
|
||||||
|
- Refresh token untuk extend session
|
||||||
|
|
||||||
|
4. **Update Frontend**
|
||||||
|
- Ganti localStorage dengan API calls
|
||||||
|
- Simpan hanya token (bukan password)
|
||||||
|
- Handle token expiration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Untuk saat ini, sistem localStorage sudah cukup untuk development/testing, tapi TIDAK AMAN untuk production!** 🔒
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
# 🚀 Push Web Javaherbal ke GitHub
|
||||||
|
|
||||||
|
## 📋 COMMAND SIAP PAKAI
|
||||||
|
|
||||||
|
Copy-paste command ini satu per satu:
|
||||||
|
|
||||||
|
### 1. Masuk ke Folder Web
|
||||||
|
```bash
|
||||||
|
cd javaherbal-v2/javaherbal2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Initialize Git
|
||||||
|
```bash
|
||||||
|
git init
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add All Files
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Commit
|
||||||
|
```bash
|
||||||
|
git commit -m "Initial commit - Web Javaherbal dengan auth backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Add Remote (Repository: frondend-web)
|
||||||
|
```bash
|
||||||
|
git remote add origin https://github.com/flemss/frondend-web.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Set Branch ke Main
|
||||||
|
```bash
|
||||||
|
git branch -M main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Push ke GitHub
|
||||||
|
```bash
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ SELESAI!
|
||||||
|
|
||||||
|
Repository: https://github.com/flemss/frondend-web
|
||||||
|
|
||||||
|
**Next:** Deploy ke Vercel!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 UPDATE SETELAH PUSH PERTAMA
|
||||||
|
|
||||||
|
Jika ada perubahan code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd javaherbal-v2/javaherbal2
|
||||||
|
|
||||||
|
# Add changes
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
git commit -m "Update: deskripsi perubahan"
|
||||||
|
|
||||||
|
# Push
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Vercel akan auto deploy! 🚀
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# JavaHerbal 🌿
|
||||||
|
|
||||||
|
Website herbal modern dengan React + TypeScript + Vite.
|
||||||
|
|
||||||
|
## Cara Menjalankan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. Jalankan dev server
|
||||||
|
npm run dev
|
||||||
|
# → buka http://localhost:5173
|
||||||
|
|
||||||
|
# 3. Build production
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fitur
|
||||||
|
- Hero section dengan animasi
|
||||||
|
- Panel Kontrol: gauge SVG, toggle power, slider suhu, chart 24 jam (Recharts)
|
||||||
|
- Grid 6 produk dengan hover effects & keranjang belanja
|
||||||
|
- Footer lengkap dengan navigasi
|
||||||
|
- TypeScript strict-free (tidak ada error)
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- React 18 + TypeScript
|
||||||
|
- Vite 5
|
||||||
|
- Recharts
|
||||||
|
- Lucide React
|
||||||
|
- Google Fonts (Playfair Display + Plus Jakarta Sans)
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
# ✅ Update Web Javaherbal - Connected to Railway Backend
|
||||||
|
|
||||||
|
## 🎉 Yang Sudah Diupdate:
|
||||||
|
|
||||||
|
### 1. API Connection
|
||||||
|
- **Backend URL:** `https://web-production-32384.up.railway.app/api`
|
||||||
|
- **File:** `src/components/ControlPanel.tsx`
|
||||||
|
|
||||||
|
### 2. Fitur Baru:
|
||||||
|
|
||||||
|
#### ✅ Real-time Data
|
||||||
|
- Suhu update otomatis setiap 5 detik
|
||||||
|
- Data dari ESP32 via backend Railway
|
||||||
|
- Grafik menampilkan history 24 jam terakhir
|
||||||
|
|
||||||
|
#### ✅ Kontrol Manual Heater
|
||||||
|
- Toggle HEATER: ON/OFF
|
||||||
|
- Kirim kontrol ke backend → MQTT → ESP32
|
||||||
|
- Status heater real-time
|
||||||
|
|
||||||
|
#### ✅ Target Suhu (Setpoint)
|
||||||
|
- Slider untuk set target suhu (30-80°C)
|
||||||
|
- Kirim setpoint ke backend
|
||||||
|
- Update otomatis
|
||||||
|
|
||||||
|
### 3. Tampilan
|
||||||
|
- **TIDAK DIUBAH** - Tampilan tetap sama seperti sebelumnya
|
||||||
|
- Hanya fungsi backend yang ditambahkan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Cara Menjalankan
|
||||||
|
|
||||||
|
### 1. Install Dependencies (jika belum)
|
||||||
|
```bash
|
||||||
|
cd javaherbal-v2/javaherbal2
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Jalankan Development Server
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Buka Browser
|
||||||
|
```
|
||||||
|
http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Login & Akses Control Panel
|
||||||
|
- Login dengan akun yang sudah terdaftar
|
||||||
|
- Klik menu "Control" atau akses `/control`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Fitur Control Panel
|
||||||
|
|
||||||
|
### Gauge Suhu
|
||||||
|
- Menampilkan suhu real-time dari ESP32
|
||||||
|
- Update setiap 5 detik
|
||||||
|
- Range: 0-100°C
|
||||||
|
|
||||||
|
### Toggle HEATER
|
||||||
|
- **ON:** Heater aktif (hijau)
|
||||||
|
- **OFF:** Heater nonaktif (abu-abu)
|
||||||
|
- Klik untuk toggle ON/OFF
|
||||||
|
- Kirim kontrol ke ESP32 via backend
|
||||||
|
|
||||||
|
### Slider Target Suhu
|
||||||
|
- Set target suhu: 30-80°C
|
||||||
|
- Drag slider untuk ubah setpoint
|
||||||
|
- Otomatis kirim ke backend
|
||||||
|
|
||||||
|
### Grafik Riwayat
|
||||||
|
- Menampilkan data 24 jam terakhir
|
||||||
|
- Update setiap 5 detik
|
||||||
|
- Hover untuk lihat detail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
ESP32 (Publish MQTT)
|
||||||
|
↓
|
||||||
|
MQTT Broker (broker.hivemq.com)
|
||||||
|
↓
|
||||||
|
Backend Railway (Subscribe & Save)
|
||||||
|
↓
|
||||||
|
MongoDB Atlas (Store data)
|
||||||
|
↓
|
||||||
|
API Endpoint (/api/sensor/latest)
|
||||||
|
↓
|
||||||
|
Web Javaherbal (Fetch & Display)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kontrol Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Web Javaherbal (Toggle Heater)
|
||||||
|
↓
|
||||||
|
POST /api/sensor/control
|
||||||
|
↓
|
||||||
|
Backend Railway (Publish MQTT)
|
||||||
|
↓
|
||||||
|
MQTT Broker
|
||||||
|
↓
|
||||||
|
ESP32 (Receive & Execute)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Test 1: Data Real-time
|
||||||
|
1. Buka web di browser
|
||||||
|
2. Login dan akses Control Panel
|
||||||
|
3. Lihat gauge suhu
|
||||||
|
4. **Expected:** Suhu muncul dan update setiap 5 detik
|
||||||
|
|
||||||
|
### Test 2: Kontrol Heater
|
||||||
|
1. Klik toggle HEATER
|
||||||
|
2. Lihat perubahan warna (hijau = ON, abu = OFF)
|
||||||
|
3. Cek di aplikasi mobile atau ESP32
|
||||||
|
4. **Expected:** Heater ON/OFF sesuai toggle
|
||||||
|
|
||||||
|
### Test 3: Set Target Suhu
|
||||||
|
1. Drag slider target suhu
|
||||||
|
2. Lihat angka berubah
|
||||||
|
3. **Expected:** Setpoint terkirim ke backend
|
||||||
|
|
||||||
|
### Test 4: Grafik History
|
||||||
|
1. Lihat grafik di panel kanan
|
||||||
|
2. Hover untuk lihat detail
|
||||||
|
3. **Expected:** Grafik menampilkan data 24 jam
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
### Problem: "Memuat data..." terus menerus
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
1. Cek backend Railway masih online:
|
||||||
|
```bash
|
||||||
|
curl https://web-production-32384.up.railway.app/api/sensor/latest
|
||||||
|
```
|
||||||
|
2. Cek browser console (F12) untuk error
|
||||||
|
3. Pastikan CORS enabled di backend
|
||||||
|
|
||||||
|
### Problem: Toggle heater tidak berfungsi
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
1. Cek network tab di browser (F12)
|
||||||
|
2. Lihat response dari POST /api/sensor/control
|
||||||
|
3. Cek backend logs di Railway
|
||||||
|
|
||||||
|
### Problem: Grafik kosong
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
1. Tunggu beberapa menit untuk data terkumpul
|
||||||
|
2. Cek ESP32 masih publish data
|
||||||
|
3. Cek MongoDB ada data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 API Endpoints yang Digunakan
|
||||||
|
|
||||||
|
### GET /api/sensor/latest
|
||||||
|
Fetch data sensor terbaru
|
||||||
|
```typescript
|
||||||
|
Response: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
device: "esp32_cabai_01",
|
||||||
|
suhu: 27.8,
|
||||||
|
heater: true,
|
||||||
|
setpoint: 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/sensor/esp32_cabai_01?limit=24
|
||||||
|
Fetch history 24 data terakhir
|
||||||
|
```typescript
|
||||||
|
Response: {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
suhu: 27.8,
|
||||||
|
createdAt: "2026-05-19T14:45:58.139Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/sensor/control
|
||||||
|
Kirim kontrol heater
|
||||||
|
```typescript
|
||||||
|
Body: {
|
||||||
|
mode: "MANUAL",
|
||||||
|
heater: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: {
|
||||||
|
success: true,
|
||||||
|
message: "Kontrol relay berhasil dikirim"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/sensor/setpoint
|
||||||
|
Set target suhu
|
||||||
|
```typescript
|
||||||
|
Body: {
|
||||||
|
setpoint: 60
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: {
|
||||||
|
success: true,
|
||||||
|
message: "Setpoint berhasil diatur"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Checklist
|
||||||
|
|
||||||
|
- [x] Connect ke backend Railway
|
||||||
|
- [x] Fetch data real-time
|
||||||
|
- [x] Display suhu di gauge
|
||||||
|
- [x] Toggle heater ON/OFF
|
||||||
|
- [x] Set target suhu
|
||||||
|
- [x] Display grafik history
|
||||||
|
- [x] Auto refresh setiap 5 detik
|
||||||
|
- [x] Error handling
|
||||||
|
- [x] Loading state
|
||||||
|
- [x] Tampilan tidak berubah
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Selesai!
|
||||||
|
|
||||||
|
Web Javaherbal sekarang sudah terhubung dengan backend Railway dan bisa kontrol heater secara manual!
|
||||||
|
|
||||||
|
**Selamat menggunakan!** 🌶️
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
# ✅ Update Hero - Sembunyikan Status Suhu Jika Belum Login
|
||||||
|
|
||||||
|
## 🎯 Perubahan
|
||||||
|
|
||||||
|
### File yang Diubah:
|
||||||
|
- **`src/components/Hero.tsx`**
|
||||||
|
|
||||||
|
### Apa yang Diubah:
|
||||||
|
Floating stat "SUHU OPTIMAL 50°C – 70°C" di hero section sekarang **hanya tampil jika user sudah login**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Logika
|
||||||
|
|
||||||
|
### Sebelum:
|
||||||
|
```tsx
|
||||||
|
{/* Floating stat */}
|
||||||
|
<div style={{ ... }}>
|
||||||
|
🌡️ SUHU OPTIMAL 50°C – 70°C
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Masalah:** Floating stat selalu tampil, bahkan jika user belum login.
|
||||||
|
|
||||||
|
### Sesudah:
|
||||||
|
```tsx
|
||||||
|
{/* Floating stat - Hanya tampil jika sudah login */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div style={{ ... }}>
|
||||||
|
🌡️ SUHU OPTIMAL 50°C – 70°C
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solusi:** Floating stat hanya tampil jika `isAuthenticated === true`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Test 1: User Belum Login
|
||||||
|
1. Buka web di browser
|
||||||
|
2. Akses halaman beranda (/)
|
||||||
|
3. **Expected:** Floating stat suhu **TIDAK TAMPIL**
|
||||||
|
4. Hero section tetap tampil normal tanpa floating stat
|
||||||
|
|
||||||
|
### Test 2: User Sudah Login
|
||||||
|
1. Login dengan akun yang valid
|
||||||
|
2. Kembali ke halaman beranda (/)
|
||||||
|
3. **Expected:** Floating stat suhu **TAMPIL**
|
||||||
|
4. Menampilkan "SUHU OPTIMAL 50°C – 70°C"
|
||||||
|
|
||||||
|
### Test 3: Logout
|
||||||
|
1. Sudah login dan floating stat tampil
|
||||||
|
2. Logout dari aplikasi
|
||||||
|
3. Kembali ke halaman beranda
|
||||||
|
4. **Expected:** Floating stat suhu **TIDAK TAMPIL**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Cara Kerja
|
||||||
|
|
||||||
|
### Autentikasi
|
||||||
|
Web menggunakan `localStorage` untuk menyimpan status login:
|
||||||
|
```typescript
|
||||||
|
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Rendering
|
||||||
|
React akan render floating stat hanya jika `isAuthenticated === true`:
|
||||||
|
```tsx
|
||||||
|
{isAuthenticated && <FloatingStat />}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Status
|
||||||
|
|
||||||
|
- ✅ Floating stat disembunyikan jika belum login
|
||||||
|
- ✅ Floating stat tampil jika sudah login
|
||||||
|
- ✅ Tampilan hero section tidak berubah
|
||||||
|
- ✅ Tidak ada perubahan pada halaman lain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Selesai!
|
||||||
|
|
||||||
|
Floating stat suhu sekarang hanya tampil untuk user yang sudah login!
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>JavaHerbal – Kualitas Herbal Terbaik</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "javaherbal",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.383.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"recharts": "^2.10.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import Navbar from './components/Navbar'
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Products from './pages/Products'
|
||||||
|
import About from './pages/About'
|
||||||
|
import Control from './pages/Control'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Register from './pages/Register'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: 'hidden' }}>
|
||||||
|
<Routes>
|
||||||
|
{/* Public routes - no login required */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/" element={<><Navbar /><Dashboard /></>} />
|
||||||
|
<Route path="/produk" element={<><Navbar /><Products /></>} />
|
||||||
|
<Route path="/tentang" element={<><Navbar /><About /></>} />
|
||||||
|
|
||||||
|
{/* Protected routes - login required */}
|
||||||
|
<Route
|
||||||
|
path="/control"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Navbar />
|
||||||
|
<Control />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,413 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
// API URL - Backend Railway
|
||||||
|
const API_URL = 'https://web-production-32384.up.railway.app/api'
|
||||||
|
|
||||||
|
interface DataPoint {
|
||||||
|
jam: number
|
||||||
|
suhu: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SensorData {
|
||||||
|
device: string
|
||||||
|
suhu: number
|
||||||
|
mode: string
|
||||||
|
heater: boolean
|
||||||
|
fan: boolean
|
||||||
|
setpoint: number | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Gauge({ value, max }: { value: number; max: number }) {
|
||||||
|
const cx = 90
|
||||||
|
const cy = 95
|
||||||
|
const r = 68
|
||||||
|
const startDeg = -210
|
||||||
|
const endDeg = 30
|
||||||
|
const totalSweep = endDeg - startDeg
|
||||||
|
|
||||||
|
function polar(angleDeg: number) {
|
||||||
|
const rad = ((angleDeg - 90) * Math.PI) / 180
|
||||||
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function arc(from: number, to: number) {
|
||||||
|
const s = polar(to)
|
||||||
|
const e = polar(from)
|
||||||
|
const large = to - from > 180 ? 1 : 0
|
||||||
|
return `M ${s.x} ${s.y} A ${r} ${r} 0 ${large} 0 ${e.x} ${e.y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillAngle = startDeg + (value / max) * totalSweep
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width="180" height="155" viewBox="0 0 180 155">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="#4a7c2f" />
|
||||||
|
<stop offset="100%" stopColor="#7ab648" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{/* Track */}
|
||||||
|
<path d={arc(startDeg, endDeg)} fill="none" stroke="#e8eee0" strokeWidth="11" strokeLinecap="round" />
|
||||||
|
{/* Fill */}
|
||||||
|
<path d={arc(startDeg, fillAngle)} fill="none" stroke="url(#gGrad)" strokeWidth="11" strokeLinecap="round" />
|
||||||
|
<text x={cx} y={cy - 22} textAnchor="middle" fill="#7ab648" fontSize="9" fontWeight="700" fontFamily="Plus Jakarta Sans, sans-serif">SUHU</text>
|
||||||
|
<text x={cx} y={cy + 3} textAnchor="middle" fill="#8a9a7a" fontSize="10" fontFamily="Plus Jakarta Sans, sans-serif">SAAT INI:</text>
|
||||||
|
<text x={cx} y={cy + 28} textAnchor="middle" fill="#1a2810" fontSize="26" fontWeight="800" fontFamily="Plus Jakarta Sans, sans-serif">{value.toFixed(1)}°C</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
width: '54px', height: '28px',
|
||||||
|
borderRadius: '14px',
|
||||||
|
background: on ? 'linear-gradient(135deg,#4a7c2f,#7ab648)' : '#ccc',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'background 0.3s',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '3px',
|
||||||
|
left: on ? '29px' : '3px',
|
||||||
|
width: '22px', height: '22px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'white',
|
||||||
|
boxShadow: '0 2px 5px rgba(0,0,0,0.22)',
|
||||||
|
transition: 'left 0.3s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%', transform: 'translateY(-50%)',
|
||||||
|
left: on ? '6px' : '14px',
|
||||||
|
fontSize: '0.6rem', fontWeight: 700,
|
||||||
|
color: on ? 'white' : '#888',
|
||||||
|
transition: 'all 0.3s',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{on ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ControlPanel() {
|
||||||
|
// State
|
||||||
|
const [data, setData] = useState<DataPoint[]>([])
|
||||||
|
const [currentTemp, setCurrentTemp] = useState(0)
|
||||||
|
const [isPowerOn, setIsPowerOn] = useState(false)
|
||||||
|
const [targetTemp, setTargetTemp] = useState(50)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const sliderPct = ((targetTemp - 30) / 50) * 100
|
||||||
|
|
||||||
|
// Fetch latest sensor data
|
||||||
|
const fetchLatestData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/sensor/latest`)
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setCurrentTemp(result.data.suhu)
|
||||||
|
setIsPowerOn(result.data.heater) // Heater status sebagai power
|
||||||
|
if (result.data.setpoint) {
|
||||||
|
setTargetTemp(result.data.setpoint)
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Gagal memuat data sensor')
|
||||||
|
console.error('Error fetching latest data:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch history data untuk grafik
|
||||||
|
const fetchHistoryData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/sensor/esp32_cabai_01?limit=24`)
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
|
// Convert data ke format grafik (24 jam terakhir)
|
||||||
|
const chartData: DataPoint[] = result.data.map((item: SensorData, index: number) => {
|
||||||
|
const date = new Date(item.createdAt)
|
||||||
|
return {
|
||||||
|
jam: date.getHours(),
|
||||||
|
suhu: item.suhu
|
||||||
|
}
|
||||||
|
}).reverse()
|
||||||
|
|
||||||
|
setData(chartData)
|
||||||
|
} else {
|
||||||
|
// Jika tidak ada data, buat data dummy
|
||||||
|
setData(Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
jam: i,
|
||||||
|
suhu: 0
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching history:', err)
|
||||||
|
// Buat data dummy jika error
|
||||||
|
setData(Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
jam: i,
|
||||||
|
suhu: 0
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle heater (kontrol manual)
|
||||||
|
const handlePowerToggle = async () => {
|
||||||
|
const newState = !isPowerOn
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/sensor/control`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode: 'MANUAL',
|
||||||
|
heater: newState
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setIsPowerOn(newState)
|
||||||
|
console.log('Heater berhasil diubah:', newState ? 'ON' : 'OFF')
|
||||||
|
} else {
|
||||||
|
alert('Gagal mengubah status heater')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Gagal mengirim kontrol')
|
||||||
|
console.error('Error sending control:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update setpoint
|
||||||
|
const handleSetpointChange = async (newTemp: number) => {
|
||||||
|
setTargetTemp(newTemp)
|
||||||
|
|
||||||
|
// Debounce: kirim ke backend setelah user selesai drag
|
||||||
|
// Untuk sementara langsung kirim
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/sensor/setpoint`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
setpoint: newTemp
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error setting setpoint:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch data on mount dan setiap 5 detik
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLatestData()
|
||||||
|
fetchHistoryData()
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchLatestData()
|
||||||
|
fetchHistoryData()
|
||||||
|
}, 5000) // Update setiap 5 detik
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ background: '#f5f2eb', padding: '5rem 2rem' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
|
||||||
|
{/* Section header */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '3rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: 'rgba(74,124,47,0.1)',
|
||||||
|
border: '1px solid rgba(74,124,47,0.2)',
|
||||||
|
borderRadius: '20px', padding: '5px 16px', marginBottom: '0.8rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#4a7c2f', fontSize: '0.78rem', fontWeight: 700, letterSpacing: '0.08em' }}>
|
||||||
|
MONITORING REAL-TIME
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: 'clamp(1.8rem,3vw,2.5rem)',
|
||||||
|
fontWeight: 700, color: '#1a2810', marginBottom: '0.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Panel Kontrol Pengeringan Pintar
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#6a7a5a', fontSize: '0.92rem' }}>
|
||||||
|
Monitor suhu
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '24px', padding: '2rem',
|
||||||
|
boxShadow: '0 4px 40px rgba(45,74,30,0.07)',
|
||||||
|
border: '1px solid rgba(74,124,47,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Card header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
marginBottom: '2rem', paddingBottom: '1rem',
|
||||||
|
borderBottom: '1px solid #e8eee0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#1a2810' }}>
|
||||||
|
Panel Kontrol Pengeringan Pintar
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '7px',
|
||||||
|
background: isPowerOn ? 'rgba(122,182,72,0.1)' : 'rgba(200,60,60,0.1)',
|
||||||
|
border: `1px solid ${isPowerOn ? 'rgba(122,182,72,0.3)' : 'rgba(200,60,60,0.3)'}`,
|
||||||
|
borderRadius: '20px', padding: '5px 14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '8px', height: '8px', borderRadius: '50%',
|
||||||
|
background: isPowerOn ? '#7ab648' : '#cc4444',
|
||||||
|
boxShadow: isPowerOn ? '0 0 6px #7ab648' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem', fontWeight: 600,
|
||||||
|
color: isPowerOn ? '#4a7c2f' : '#cc4444',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Status: {isPowerOn ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', gap: '2.5rem', alignItems: 'start' }}>
|
||||||
|
{/* Left controls */}
|
||||||
|
<div>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '2rem', color: '#8a9a7a' }}>
|
||||||
|
Memuat data...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '2rem', color: '#cc4444' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '1.2rem' }}>
|
||||||
|
<Gauge value={currentTemp} max={100} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Power row - Kontrol Manual Heater */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
background: '#f8faf5', borderRadius: '12px', padding: '11px 14px',
|
||||||
|
marginBottom: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 700, color: '#2d4a1e', letterSpacing: '0.05em' }}>
|
||||||
|
HEATER
|
||||||
|
</span>
|
||||||
|
<Toggle on={isPowerOn} onToggle={handlePowerToggle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slider */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f8faf5', borderRadius: '12px', padding: '11px 14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '10px' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 700, color: '#2d4a1e', letterSpacing: '0.05em' }}>
|
||||||
|
TARGET SUHU
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.88rem', fontWeight: 800, color: '#d48c2a' }}>
|
||||||
|
{targetTemp}°C
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
.temp-slider { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; border-radius: 3px; outline: none; cursor: pointer; }
|
||||||
|
.temp-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #d48c2a; box-shadow: 0 2px 6px rgba(212,140,42,0.5); cursor: pointer; }
|
||||||
|
.temp-slider::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: #d48c2a; border: none; cursor: pointer; }
|
||||||
|
`}</style>
|
||||||
|
<input
|
||||||
|
className="temp-slider"
|
||||||
|
type="range" min={30} max={80}
|
||||||
|
value={targetTemp}
|
||||||
|
onChange={(e) => handleSetpointChange(Number(e.target.value))}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right,#d48c2a ${sliderPct}%,#e8eee0 ${sliderPct}%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right chart */}
|
||||||
|
<div>
|
||||||
|
<h4 style={{ fontSize: '0.95rem', fontWeight: 700, color: '#1a2810', marginBottom: '1.2rem' }}>
|
||||||
|
Riwayat 24 Jam
|
||||||
|
</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<AreaChart data={data} margin={{ top: 5, right: 10, bottom: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="aGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#7ab648" stopOpacity={0.28} />
|
||||||
|
<stop offset="95%" stopColor="#7ab648" stopOpacity={0.01} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0e8" />
|
||||||
|
<XAxis dataKey="jam" tick={{ fontSize: 11, fill: '#8a9a7a' }} tickLine={false} axisLine={{ stroke: '#e8eee0' }} />
|
||||||
|
<YAxis domain={[0, 100]} ticks={[0, 25, 50, 75, 100]} tick={{ fontSize: 11, fill: '#8a9a7a' }} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'white', border: '1px solid #e8eee0', borderRadius: '10px', fontSize: '12px' }}
|
||||||
|
formatter={(val: number) => [`${val.toFixed(1)}°C`, 'Suhu']}
|
||||||
|
labelFormatter={(l: number) => `Jam ${l}:00`}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="suhu" stroke="#7ab648" strokeWidth={2.5} fill="url(#aGrad)" dot={false} activeDot={{ r: 5, fill: '#4a7c2f' }} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Leaf, Mail, Phone, MapPin, Instagram, Facebook, Youtube, LucideProps } from 'lucide-react'
|
||||||
|
|
||||||
|
// ── types ────────────────────────────────────────────────────────────────────
|
||||||
|
type IconComponent = React.FC<LucideProps>
|
||||||
|
|
||||||
|
interface LinkColData { title: string; links: string[] }
|
||||||
|
interface ContactData { Icon: IconComponent; text: string }
|
||||||
|
|
||||||
|
// ── data ─────────────────────────────────────────────────────────────────────
|
||||||
|
const SOCIAL_ICONS: IconComponent[] = [Instagram, Facebook, Youtube]
|
||||||
|
|
||||||
|
const LINK_COLS: LinkColData[] = [
|
||||||
|
{ title: 'Produk', links: ['Rempah Kering','Jamu Tradisional', 'Paket Bundle'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CONTACT_ITEMS: ContactData[] = [
|
||||||
|
{ Icon: Phone, text: '+62 812-3456-7890' },
|
||||||
|
{ Icon: Mail, text: 'info@javaherbal.id' },
|
||||||
|
{ Icon: MapPin, text: 'Ambulu, Jawa Timur' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LEGAL_LINKS = ['Kebijakan Privasi', 'Syarat & Ketentuan', 'Kebijakan Cookie']
|
||||||
|
|
||||||
|
// ── sub-components ────────────────────────────────────────────────────────────
|
||||||
|
function HoverLink({ children, dimColor = 'rgba(255,255,255,0.6)', size = '0.85rem' }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
dimColor?: string
|
||||||
|
size?: string
|
||||||
|
}) {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
style={{
|
||||||
|
color: hovered ? '#7ab648' : dimColor,
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontSize: size,
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SocialBtn({ Icon }: { Icon: IconComponent }) {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
width: '36px', height: '36px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: hovered ? 'rgba(122,182,72,0.28)' : 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: hovered ? '#7ab648' : 'rgba(255,255,255,0.7)',
|
||||||
|
cursor: 'pointer', transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkCol({ title, links }: LinkColData) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4 style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '1.2rem', color: '#a8d878' }}>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{links.map((link) => (
|
||||||
|
<li key={link} style={{ marginBottom: '9px' }}>
|
||||||
|
<HoverLink>{link}</HoverLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactRow({ Icon, text }: ContactData) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||||
|
<Icon size={15} color="#7ab648" />
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: '0.84rem' }}>{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main component ────────────────────────────────────────────────────────────
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg,#1a2e0f,#2d4a1e)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '4rem 2rem 2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
|
||||||
|
{/* Top grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '2fr 1fr 1fr 1fr',
|
||||||
|
gap: '3rem',
|
||||||
|
marginBottom: '3rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Brand column */}
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '1rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px', height: '36px',
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Leaf size={20} color="white" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: '1.4rem', fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Java<span style={{ color: '#7ab648' }}>Herbal</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: 'rgba(255,255,255,0.6)', fontSize: '0.87rem',
|
||||||
|
lineHeight: 1.75, marginBottom: '1.5rem', maxWidth: '280px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Teknologi pengeringan untuk menjaga khasiat alami rempah Nusantara.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
{SOCIAL_ICONS.map((Icon, i) => (
|
||||||
|
<SocialBtn key={i} Icon={Icon} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link columns */}
|
||||||
|
{LINK_COLS.map((col) => (
|
||||||
|
<LinkCol key={col.title} {...col} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Contact column */}
|
||||||
|
<div>
|
||||||
|
<h4 style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '1.2rem', color: '#a8d878' }}>
|
||||||
|
Hubungi Kami
|
||||||
|
</h4>
|
||||||
|
{CONTACT_ITEMS.map((item, i) => (
|
||||||
|
<ContactRow key={i} {...item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
paddingTop: '1.5rem',
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
flexWrap: 'wrap', gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.38)', fontSize: '0.8rem' }}>
|
||||||
|
© 2025 JavaHerbal.
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
|
||||||
|
{LEGAL_LINKS.map((item) => (
|
||||||
|
<span key={item}>
|
||||||
|
<HoverLink dimColor="rgba(255,255,255,0.38)" size="0.8rem">
|
||||||
|
{item}
|
||||||
|
</HoverLink>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Thermometer, Shield, } from 'lucide-react'
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{ icon: <Thermometer size={22} />, label: 'Presisi Suhu' },
|
||||||
|
{ icon: <Shield size={22} />, label: 'Kualitas Terjaga' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function CTAButton({
|
||||||
|
children,
|
||||||
|
primary,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
primary?: boolean
|
||||||
|
}) {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
|
const baseStyle: React.CSSProperties = {
|
||||||
|
padding: '14px 28px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryStyle: React.CSSProperties = {
|
||||||
|
...baseStyle,
|
||||||
|
background: 'linear-gradient(135deg,#d48c2a,#f0b855)',
|
||||||
|
color: 'white',
|
||||||
|
boxShadow: hovered
|
||||||
|
? '0 8px 28px rgba(212,140,42,0.55)'
|
||||||
|
: '0 4px 18px rgba(212,140,42,0.38)',
|
||||||
|
transform: hovered ? 'translateY(-2px)' : 'translateY(0)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondaryStyle: React.CSSProperties = {
|
||||||
|
...baseStyle,
|
||||||
|
background: hovered ? 'rgba(255,255,255,0.1)' : 'transparent',
|
||||||
|
color: 'white',
|
||||||
|
border: `2px solid ${hovered ? 'rgba(255,255,255,0.7)' : 'rgba(255,255,255,0.3)'}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
style={primary ? primaryStyle : secondaryStyle}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Hero() {
|
||||||
|
// Cek apakah user sudah login
|
||||||
|
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(135deg,#1a2e0f 0%,#2d4a1e 45%,#4a7c2f 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '80px 2rem 4rem',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background blobs */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '-120px', right: '-120px',
|
||||||
|
width: '520px', height: '520px', borderRadius: '50%',
|
||||||
|
background: 'radial-gradient(circle,rgba(122,182,72,0.14) 0%,transparent 70%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', bottom: '-60px', left: '35%',
|
||||||
|
width: '320px', height: '320px', borderRadius: '50%',
|
||||||
|
background: 'radial-gradient(circle,rgba(212,140,42,0.1) 0%,transparent 70%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '1200px', margin: '0 auto', width: '100%',
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '4rem', alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* LEFT */}
|
||||||
|
<div style={{ animation: 'fadeUp 0.7s ease both' }}>
|
||||||
|
{/* Badge */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: '8px',
|
||||||
|
background: 'rgba(122,182,72,0.18)',
|
||||||
|
border: '1px solid rgba(122,182,72,0.38)',
|
||||||
|
borderRadius: '20px', padding: '6px 16px', marginBottom: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '8px', height: '8px', borderRadius: '50%',
|
||||||
|
background: '#7ab648', animation: 'pulseGlow 2s infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: '#a8d878', fontSize: '0.8rem', fontWeight: 700, letterSpacing: '0.06em' }}>
|
||||||
|
TEKNOLOGI PENGERINGAN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: 'clamp(2rem,4vw,3.2rem)',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: 'white',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
marginBottom: '1.2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kualitas Herbal Terbaik,
|
||||||
|
<br />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#d48c2a)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kering Sempurna.
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
fontSize: '1rem', lineHeight: 1.75,
|
||||||
|
marginBottom: '2.5rem', maxWidth: '420px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Teknologi pengeringan untuk menjaga khasiat alami rempah Nusantara.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '3rem', flexWrap: 'wrap' }}>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature icons */}
|
||||||
|
<div style={{ display: 'flex', gap: '2.5rem' }}>
|
||||||
|
{FEATURES.map((f) => (
|
||||||
|
<div key={f.label} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px', height: '48px',
|
||||||
|
background: 'rgba(122,182,72,0.15)',
|
||||||
|
border: '1px solid rgba(122,182,72,0.3)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#7ab648',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.icon}
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.65)', fontSize: '0.78rem', fontWeight: 500, textAlign: 'center' }}>
|
||||||
|
{f.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT – decorative card */}
|
||||||
|
<div style={{ animation: 'fadeUp 0.9s ease both', position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: '24px',
|
||||||
|
aspectRatio: '4/3',
|
||||||
|
background: 'linear-gradient(135deg,#3d5c25,#2d4a1e)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: '0 30px 80px rgba(0,0,0,0.4)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
background: `
|
||||||
|
radial-gradient(ellipse at 30% 50%,rgba(180,120,40,0.55) 0%,transparent 55%),
|
||||||
|
radial-gradient(ellipse at 70% 25%,rgba(80,140,40,0.45) 0%,transparent 55%),
|
||||||
|
radial-gradient(ellipse at 55% 80%,rgba(140,80,30,0.35) 0%,transparent 55%)
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '6.5rem', filter: 'drop-shadow(0 10px 30px rgba(0,0,0,0.45))', zIndex: 1 }}>
|
||||||
|
🌿
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating stat - Hanya tampil jika sudah login */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', bottom: '-18px', left: '-18px',
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '12px 18px',
|
||||||
|
boxShadow: '0 10px 36px rgba(0,0,0,0.18)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '40px', height: '40px',
|
||||||
|
background: 'linear-gradient(135deg,#4a7c2f,#7ab648)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🌡️
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.68rem', color: '#8a9a7a', fontWeight: 600 }}>SUHU OPTIMAL</div>
|
||||||
|
<div style={{ fontSize: '1.05rem', fontWeight: 800, color: '#1a2810' }}>50°C – 70°C</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { Leaf, LogOut } from 'lucide-react'
|
||||||
|
|
||||||
|
const NAV_LINKS = [
|
||||||
|
{ label: 'Beranda', path: '/' },
|
||||||
|
{ label: 'Produk', path: '/produk' },
|
||||||
|
{ label: 'Tentang Kami', path: '/tentang' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PROTECTED_LINKS = [
|
||||||
|
{ label: 'Control Panel', path: '/control' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function NavLink({ label, path }: { label: string; path: string }) {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
const location = useLocation()
|
||||||
|
const isActive = location.pathname === path
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={path}
|
||||||
|
style={{
|
||||||
|
color: isActive ? '#7ab648' : hovered ? '#7ab648' : 'rgba(255,255,255,0.85)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: isActive ? 600 : 500,
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true'
|
||||||
|
const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{}')
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
localStorage.removeItem('isAuthenticated')
|
||||||
|
localStorage.removeItem('currentUser')
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogin() {
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0, left: 0, right: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
background: 'rgba(27,46,15,0.96)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
height: '64px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 2rem',
|
||||||
|
boxShadow: '0 2px 24px rgba(0,0,0,0.18)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<Link to="/" style={{ display: 'flex', alignItems: 'center', gap: '8px', textDecoration: 'none' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '32px', height: '32px',
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Leaf size={18} color="white" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Java<span style={{ color: '#7ab648' }}>Herbal</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div style={{ display: 'flex', gap: '2rem' }}>
|
||||||
|
{NAV_LINKS.map((link) => (
|
||||||
|
<NavLink key={link.path} label={link.label} path={link.path} />
|
||||||
|
))}
|
||||||
|
{isAuthenticated && PROTECTED_LINKS.map((link) => (
|
||||||
|
<NavLink key={link.path} label={link.label} path={link.path} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.8rem', alignItems: 'center' }}>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
{currentUser.name && (
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.85)', fontSize: '0.85rem', fontWeight: 500 }}>
|
||||||
|
{currentUser.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 18px',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
Keluar
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 18px',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Masuk
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ShoppingCart, Star } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
price: string
|
||||||
|
unit: string
|
||||||
|
emoji: string
|
||||||
|
badge?: string
|
||||||
|
rating: number
|
||||||
|
reviews: number
|
||||||
|
bgColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRODUCTS: Product[] = [
|
||||||
|
{ id: 1, name: 'Cabai Jawa Kering Premium', description: 'Dikeringkan pada suhu presisi untuk mempertahankan warna dan aroma.', price: 'Rp 85.000', unit: '100g', emoji: '🌶️', badge: 'TERLARIS', rating: 4.9, reviews: 128, bgColor: 'linear-gradient(135deg,#8B1A0A,#C0392B)' },
|
||||||
|
{ id: 2, name: 'Cabai Jawa', description: 'Proses higienis, warna dan rasa alami terjaga sepanjang proses pengeringan.', price: 'Rp 30.000', unit: '100g', emoji: '🌿', rating: 4.7, reviews: 84, bgColor: 'linear-gradient(135deg,#5c3a1e,#8b5e3c)' },
|
||||||
|
{ id: 3, name: 'Teh Rempah Hangat', description: 'Campuran herbal pilihan untuk kesehatan yang menyegarkan jiwa dan raga.', price: 'Rp 55.000', unit: 'pack', emoji: '🍵', badge: 'BARU', rating: 4.8, reviews: 56, bgColor: 'linear-gradient(135deg,#4a3220,#7a5c3a)' },
|
||||||
|
{ id: 4, name: 'Jahe Merah Kering', description: 'Jahe merah pilihan kaya antioksidan, diproses tanpa bahan pengawet.', price: 'Rp 45.000', unit: '100g', emoji: '🫚', rating: 4.6, reviews: 72, bgColor: 'linear-gradient(135deg,#7a2010,#b84030)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CATEGORIES = []
|
||||||
|
|
||||||
|
function ProductCard({ product, index }: { product: Product; index: number }) {
|
||||||
|
const [added, setAdded] = useState(false)
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
setAdded(true)
|
||||||
|
setTimeout(() => setAdded(false), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '20px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: hovered ? '0 14px 44px rgba(45,74,30,0.15)' : '0 2px 20px rgba(45,74,30,0.06)',
|
||||||
|
border: '1px solid rgba(74,124,47,0.08)',
|
||||||
|
transform: hovered ? 'translateY(-6px)' : 'translateY(0)',
|
||||||
|
transition: 'transform 0.3s, box-shadow 0.3s',
|
||||||
|
animation: `fadeUp 0.5s ease ${index * 0.08}s both`,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
{/* Image area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '175px',
|
||||||
|
background: product.bgColor,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '12px', left: '12px',
|
||||||
|
width: '26px', height: '26px',
|
||||||
|
background: 'rgba(0,0,0,0.28)',
|
||||||
|
borderRadius: '7px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: 'white', fontSize: '0.72rem', fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{product.id}
|
||||||
|
</div>
|
||||||
|
{product.badge && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '12px', right: '12px',
|
||||||
|
background: product.badge === 'TERLARIS' ? '#d48c2a' : '#4a7c2f',
|
||||||
|
color: 'white', fontSize: '0.62rem', fontWeight: 700,
|
||||||
|
padding: '3px 9px', borderRadius: '6px', letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{product.badge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: '3.8rem', filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.3))' }}>
|
||||||
|
{product.emoji}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ padding: '1.1rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '6px' }}>
|
||||||
|
<Star size={12} fill="#d48c2a" color="#d48c2a" />
|
||||||
|
<span style={{ fontSize: '0.76rem', fontWeight: 700, color: '#d48c2a' }}>{product.rating}</span>
|
||||||
|
<span style={{ fontSize: '0.74rem', color: '#a0a898' }}>({product.reviews})</span>
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: '0.92rem', fontWeight: 700, color: '#1a2810', marginBottom: '5px', lineHeight: 1.3 }}>
|
||||||
|
{product.name}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.78rem', color: '#7a8a6a', lineHeight: 1.5, marginBottom: '1rem' }}>
|
||||||
|
{product.description}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: '1rem', fontWeight: 800, color: '#1a2810' }}>{product.price}</span>
|
||||||
|
<span style={{ fontSize: '0.74rem', color: '#a0a898' }}> / {product.unit}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
style={{
|
||||||
|
background: added
|
||||||
|
? 'linear-gradient(135deg,#4a7c2f,#7ab648)'
|
||||||
|
: 'linear-gradient(135deg,#d48c2a,#f0b855)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '7px 12px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', gap: '5px',
|
||||||
|
transition: 'all 0.3s',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryBtn({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '7px 16px', borderRadius: '20px',
|
||||||
|
fontSize: '0.82rem', fontWeight: 600,
|
||||||
|
border: active ? 'none' : '1px solid #e8eee0',
|
||||||
|
background: active ? 'linear-gradient(135deg,#2d4a1e,#4a7c2f)' : 'white',
|
||||||
|
color: active ? 'white' : '#4a5c3a',
|
||||||
|
cursor: 'pointer', transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductGrid() {
|
||||||
|
const [activeCategory, setActiveCategory] = useState('Semua')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ background: 'white', padding: '5rem 2rem' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
|
||||||
|
{/* Header row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex', justifyContent: 'flex-end', alignItems: 'flex-end',
|
||||||
|
marginBottom: '2.5rem', flexWrap: 'wrap', gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<CategoryBtn
|
||||||
|
key={cat} label={cat}
|
||||||
|
active={activeCategory === cat}
|
||||||
|
onClick={() => setActiveCategory(cat)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill,minmax(270px,1fr))',
|
||||||
|
gap: '1.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PRODUCTS.map((p, i) => <ProductCard key={p.id} product={p} index={i} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Load more */}
|
||||||
|
<LoadMoreBtn />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadMoreBtn() {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
navigate('/produk')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '3rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{
|
||||||
|
background: hovered ? '#2d4a1e' : 'transparent',
|
||||||
|
border: '2px solid #2d4a1e',
|
||||||
|
color: hovered ? 'white' : '#2d4a1e',
|
||||||
|
padding: '12px 36px', borderRadius: '12px',
|
||||||
|
fontSize: '0.9rem', fontWeight: 700, cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
Lihat Semua Produk
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true'
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
background-color: #f0ede6;
|
||||||
|
color: #1a2810;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseGlow {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(122,182,72,0.5); }
|
||||||
|
50% { box-shadow: 0 0 0 8px rgba(122,182,72,0); }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Leaf, Target, Heart, Zap, Award, Clock, Lightbulb } from 'lucide-react'
|
||||||
|
import Footer from '../components/Footer'
|
||||||
|
|
||||||
|
const VALUES = [
|
||||||
|
{ icon: Heart, title: 'Kualitas Terbaik', desc: 'Kami berkomitmen memberikan produk herbal berkualitas tinggi' },
|
||||||
|
{ icon: Leaf, title: 'Alami & Organik', desc: 'Semua produk dari bahan alami tanpa bahan kimia berbahaya' },
|
||||||
|
{ icon: Zap, title: 'Teknologi Modern', desc: 'Menggunakan teknologi pengeringan presisi untuk hasil optimal' },
|
||||||
|
{ icon: Award, title: 'Terpercaya', desc: 'Dipercaya oleh pelanggan setia kami' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TIMELINE = [
|
||||||
|
{ year: '2023', title: 'Awal Usaha', desc: 'Memulai usaha kecil pengolahan cabai jawa di rumah' },
|
||||||
|
{ year: '2024', title: 'Alat Pertama', desc: 'Membeli alat pengering sederhana untuk meningkatkan kualitas' },
|
||||||
|
{ year: '2025', title: 'Berkembang', desc: 'Menambah variasi produk dan pelanggan tetap' },
|
||||||
|
{ year: '2026', title: 'Teknologi Baru', desc: 'Upgrade ke alat pengering dengan kontrol suhu presisi' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const INNOVATIONS = [
|
||||||
|
{ title: 'Alat Pengering Modern', desc: 'Menggunakan alat pengering dengan kontrol suhu untuk hasil lebih baik', icon: '🌡️' },
|
||||||
|
{ title: 'Proses Higienis', desc: 'Menjaga kebersihan dan kualitas produk di setiap tahap', icon: '✨' },
|
||||||
|
{ title: 'Harga Terjangkau', desc: 'Produk berkualitas dengan harga yang ramah di kantong', icon: '💰' },
|
||||||
|
{ title: 'Produksi Rumahan', desc: 'Diproduksi dengan penuh perhatian dan kehati-hatian', icon: '🏠' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function ValueCard({ icon: Icon, title, desc }: { icon: any; title: string; desc: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
boxShadow: '0 4px 20px rgba(45,74,30,0.08)',
|
||||||
|
border: '1px solid rgba(74,124,47,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={32} color="white" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#1a2810', marginBottom: '0.5rem' }}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.88rem', color: '#6a7a5a', lineHeight: 1.6 }}>
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function About() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ paddingTop: '64px', minHeight: '100vh', background: '#f5f2eb' }}>
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg,#1a2e0f 0%,#2d4a1e 45%,#4a7c2f 100%)',
|
||||||
|
padding: '5rem 2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: 'rgba(122,182,72,0.18)',
|
||||||
|
border: '1px solid rgba(122,182,72,0.38)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '6px 16px',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#a8d878', fontSize: '0.8rem', fontWeight: 700, letterSpacing: '0.06em' }}>
|
||||||
|
TENTANG KAMI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: 'clamp(2rem,4vw,3rem)',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
JavaHerbal
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.8)', fontSize: '1.1rem', lineHeight: 1.75 }}>
|
||||||
|
Menjaga Tradisi, Menghadirkan Inovasi
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Story Section */}
|
||||||
|
<section style={{ padding: '5rem 2rem', background: 'white' }}>
|
||||||
|
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||||
|
<Target size={32} color="#4a7c2f" />
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1a2810',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cerita Kami
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '1rem', color: '#4a5c3a', lineHeight: 1.8, marginBottom: '1.5rem' }}>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>
|
||||||
|
JavaHerbal adalah usaha rumahan yang dimulai pada tahun 2023 dengan fokus pada
|
||||||
|
pengolahan cabai jawa dan rempah-rempah tradisional. Kami percaya bahwa produk berkualitas
|
||||||
|
tidak harus mahal dan bisa diproduksi dengan skala kecil namun tetap higienis.
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>
|
||||||
|
Dengan menggunakan alat pengering modern yang dapat mengatur suhu antara 50°C - 70°C,
|
||||||
|
kami berusaha menghasilkan produk cabai jawa kering yang tetap mempertahankan warna,
|
||||||
|
aroma, dan khasiat alaminya. Semua proses dilakukan dengan penuh kehati-hatian di rumah.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Meskipun masih berskala kecil, kami terus belajar dan berkembang untuk memberikan
|
||||||
|
produk terbaik kepada pelanggan setia kami. Setiap produk dibuat dengan penuh perhatian
|
||||||
|
dan dedikasi untuk menjaga kualitas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Timeline Section */}
|
||||||
|
<section style={{ padding: '5rem 2rem', background: '#f5f2eb' }}>
|
||||||
|
<div style={{ maxWidth: '1000px', margin: '0 auto' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '3rem' }}>
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||||
|
<Clock size={32} color="#4a7c2f" />
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1a2810',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sejarah Kami
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: '#6a7a5a', fontSize: '1rem' }}>
|
||||||
|
Perjalanan usaha kami dari awal hingga sekarang
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '0',
|
||||||
|
bottom: '0',
|
||||||
|
width: '2px',
|
||||||
|
background: 'linear-gradient(180deg, #7ab648, #4a7c2f)',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{TIMELINE.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: i % 2 === 0 ? '1fr auto 1fr' : '1fr auto 1fr',
|
||||||
|
gap: '2rem',
|
||||||
|
marginBottom: '3rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i % 2 === 0 ? (
|
||||||
|
<>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
boxShadow: '0 4px 20px rgba(45,74,30,0.1)',
|
||||||
|
border: '1px solid rgba(74,124,47,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#1a2810', marginBottom: '0.5rem' }}>
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.88rem', color: '#6a7a5a', lineHeight: 1.6 }}>
|
||||||
|
{item.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
boxShadow: '0 4px 12px rgba(74,124,47,0.3)',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.year}
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
boxShadow: '0 4px 12px rgba(74,124,47,0.3)',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.year}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'left' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
boxShadow: '0 4px 20px rgba(45,74,30,0.1)',
|
||||||
|
border: '1px solid rgba(74,124,47,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#1a2810', marginBottom: '0.5rem' }}>
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.88rem', color: '#6a7a5a', lineHeight: 1.6 }}>
|
||||||
|
{item.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Innovation Section */}
|
||||||
|
<section style={{ padding: '5rem 2rem', background: 'white' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '3rem' }}>
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||||
|
<Lightbulb size={32} color="#4a7c2f" />
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1a2810',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Inovasi Kami
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: '#6a7a5a', fontSize: '1rem' }}>
|
||||||
|
Upaya kami meningkatkan kualitas produk
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
gap: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{INNOVATIONS.map((innovation, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #f8faf5, #ffffff)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '1px solid rgba(74,124,47,0.1)',
|
||||||
|
boxShadow: '0 4px 20px rgba(45,74,30,0.08)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>{innovation.icon}</div>
|
||||||
|
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#1a2810', marginBottom: '0.5rem' }}>
|
||||||
|
{innovation.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.88rem', color: '#6a7a5a', lineHeight: 1.6 }}>
|
||||||
|
{innovation.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Values Section */}
|
||||||
|
<section style={{ padding: '5rem 2rem', background: '#f5f2eb' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '3rem' }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1a2810',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Nilai-Nilai Kami
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#6a7a5a', fontSize: '1rem' }}>
|
||||||
|
Prinsip yang kami pegang dalam setiap langkah
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||||
|
gap: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{VALUES.map((value, i) => (
|
||||||
|
<ValueCard key={i} icon={value.icon} title={value.title} desc={value.desc} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
padding: '4rem 2rem',
|
||||||
|
background: 'linear-gradient(135deg,#2d4a1e,#4a7c2f)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: '700px', margin: '0 auto' }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dukung Usaha Kami
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.8)', fontSize: '1rem', marginBottom: '2rem', lineHeight: 1.7 }}>
|
||||||
|
Terima kasih telah mendukung usaha kami. Mari bersama-sama menikmati
|
||||||
|
produk herbal berkualitas yang dibuat dengan penuh dedikasi.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg,#d48c2a,#f0b855)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '14px 32px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 4px 18px rgba(212,140,42,0.38)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hubungi Kami
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ControlPanel from '../components/ControlPanel'
|
||||||
|
import Footer from '../components/Footer'
|
||||||
|
|
||||||
|
export default function Control() {
|
||||||
|
return (
|
||||||
|
<div style={{ paddingTop: '64px' }}>
|
||||||
|
<ControlPanel />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Thermometer, Leaf, Sparkles, ChefHat } from 'lucide-react'
|
||||||
|
import Hero from '../components/Hero'
|
||||||
|
import Footer from '../components/Footer'
|
||||||
|
|
||||||
|
const BENEFITS = [
|
||||||
|
{ title: 'Meningkatkan Metabolisme', desc: 'Capsaicin dalam cabai jawa membantu meningkatkan metabolisme tubuh' },
|
||||||
|
{ title: 'Kaya Antioksidan', desc: 'Mengandung vitamin C dan A yang tinggi untuk menangkal radikal bebas' },
|
||||||
|
{ title: 'Melancarkan Pencernaan', desc: 'Membantu merangsang produksi enzim pencernaan' },
|
||||||
|
{ title: 'Meredakan Nyeri', desc: 'Sifat analgesik alami membantu meredakan nyeri sendi dan otot' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const RECIPES = [
|
||||||
|
{ name: 'Jamu Penghangat Badan', emoji: '🍵', desc: 'Minuman jamu hangat untuk meningkatkan metabolisme tubuh' },
|
||||||
|
{ name: 'Teh Herbal Cabai', emoji: '☕', desc: 'Teh herbal dengan campuran cabai untuk stamina' },
|
||||||
|
{ name: 'Minyak Gosok Herbal', emoji: '💧', desc: 'Minyak gosok dengan ekstrak cabai untuk meredakan nyeri' },
|
||||||
|
{ name: 'Kapsul Herbal', emoji: '💊', desc: 'Suplemen herbal untuk meningkatkan daya tahan tubuh' },
|
||||||
|
{ name: 'Ramuan Jamu Tradisional', emoji: '🌿', desc: 'Campuran jamu khas untuk kesehatan pencernaan' },
|
||||||
|
{ name: 'Minuman Herbal Hangat', emoji: '🥤', desc: 'Minuman herbal untuk melancarkan peredaran darah' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero />
|
||||||
|
|
||||||
|
{/* About Tool Section */}
|
||||||
|
<section style={{ background: 'white', padding: '5rem 2rem' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '3rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: 'rgba(74,124,47,0.1)',
|
||||||
|
border: '1px solid rgba(74,124,47,0.2)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '5px 16px',
|
||||||
|
marginBottom: '0.8rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#4a7c2f', fontSize: '0.78rem', fontWeight: 700, letterSpacing: '0.08em' }}>
|
||||||
|
TEKNOLOGI KAMI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: 'clamp(1.8rem,3vw,2.5rem)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1a2810',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alat Pengering Presisi
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#6a7a5a', fontSize: '1rem', maxWidth: '700px', margin: '0 auto', lineHeight: 1.7 }}>
|
||||||
|
Alat pengering kami menggunakan teknologi kontrol suhu presisi yang dapat diatur antara 50°C - 70°C.
|
||||||
|
Dengan sistem monitoring real-time, kami memastikan setiap rempah dikeringkan pada suhu optimal untuk
|
||||||
|
mempertahankan khasiat, warna, aroma, dan nutrisi alami.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||||
|
gap: '2rem',
|
||||||
|
marginTop: '3rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f8faf5',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '1px solid rgba(74,124,47,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Thermometer size={32} color="white" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#1a2810', marginBottom: '0.5rem' }}>
|
||||||
|
Kontrol Suhu Presisi
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.88rem', color: '#6a7a5a', lineHeight: 1.6 }}>
|
||||||
|
Suhu dapat dikontrol dengan akurat untuk hasil pengeringan optimal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f8faf5',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '1px solid rgba(74,124,47,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={32} color="white" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#1a2810', marginBottom: '0.5rem' }}>
|
||||||
|
Kualitas Terjaga
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.88rem', color: '#6a7a5a', lineHeight: 1.6 }}>
|
||||||
|
Mempertahankan warna, aroma, dan nutrisi alami rempah
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#f8faf5',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '1px solid rgba(74,124,47,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Leaf size={32} color="white" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#1a2810', marginBottom: '0.5rem' }}>
|
||||||
|
Higienis & Aman
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.88rem', color: '#6a7a5a', lineHeight: 1.6 }}>
|
||||||
|
Proses pengeringan yang bersih dan aman untuk konsumsi
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Benefits Section */}
|
||||||
|
<section style={{ background: '#f5f2eb', padding: '5rem 2rem' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '3rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: 'rgba(212,140,42,0.1)',
|
||||||
|
border: '1px solid rgba(212,140,42,0.2)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '5px 16px',
|
||||||
|
marginBottom: '0.8rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#d48c2a', fontSize: '0.78rem', fontWeight: 700, letterSpacing: '0.08em' }}>
|
||||||
|
MANFAAT KESEHATAN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: 'clamp(1.8rem,3vw,2.5rem)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1a2810',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kasiat Cabai Jawa
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#6a7a5a', fontSize: '1rem', maxWidth: '700px', margin: '0 auto', lineHeight: 1.7 }}>
|
||||||
|
Cabai jawa kaya akan nutrisi dan memiliki berbagai manfaat untuk kesehatan tubuh
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
gap: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{BENEFITS.map((benefit, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '1.8rem',
|
||||||
|
border: '1px solid rgba(74,124,47,0.1)',
|
||||||
|
boxShadow: '0 2px 12px rgba(45,74,30,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
background: '#d48c2a',
|
||||||
|
borderRadius: '50%',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 700, color: '#1a2810', marginBottom: '0.5rem' }}>
|
||||||
|
{benefit.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.88rem', color: '#6a7a5a', lineHeight: 1.6 }}>
|
||||||
|
{benefit.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Recipes Section */}
|
||||||
|
<section style={{ background: 'white', padding: '5rem 2rem' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '3rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: 'rgba(74,124,47,0.1)',
|
||||||
|
border: '1px solid rgba(74,124,47,0.2)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '5px 16px',
|
||||||
|
marginBottom: '0.8rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#4a7c2f', fontSize: '0.78rem', fontWeight: 700, letterSpacing: '0.08em' }}>
|
||||||
|
INSPIRASI OLAHAN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: 'clamp(1.8rem,3vw,2.5rem)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1a2810',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Produk Herbal dari Cabai Jawa
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: '#6a7a5a', fontSize: '1rem', maxWidth: '700px', margin: '0 auto', lineHeight: 1.7 }}>
|
||||||
|
Cabai jawa kering dapat diolah menjadi berbagai produk herbal untuk kesehatan
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||||
|
gap: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{RECIPES.map((recipe, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg,#f8faf5,#ffffff)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '1px solid rgba(74,124,47,0.1)',
|
||||||
|
boxShadow: '0 2px 12px rgba(45,74,30,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '3.5rem', marginBottom: '1rem' }}>{recipe.emoji}</div>
|
||||||
|
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#1a2810', marginBottom: '0.5rem' }}>
|
||||||
|
{recipe.name}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.88rem', color: '#6a7a5a', lineHeight: 1.6 }}>
|
||||||
|
{recipe.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Hero from '../components/Hero'
|
||||||
|
import ControlPanel from '../components/ControlPanel'
|
||||||
|
import Footer from '../components/Footer'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Hero />
|
||||||
|
<ControlPanel />
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,278 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { Leaf, Mail, Lock, Eye, EyeOff } from 'lucide-react'
|
||||||
|
|
||||||
|
// API URL - Backend Railway
|
||||||
|
const API_URL = 'https://web-production-32384.up.railway.app/api'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Kirim request ke backend
|
||||||
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Simpan token dan user data
|
||||||
|
localStorage.setItem('token', result.token)
|
||||||
|
localStorage.setItem('isAuthenticated', 'true')
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(result.user))
|
||||||
|
|
||||||
|
console.log('✅ Login berhasil:', result.user.email)
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
setError(result.message || 'Login gagal')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error login:', error)
|
||||||
|
setError('Terjadi kesalahan. Pastikan backend sudah berjalan.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(135deg,#1a2e0f 0%,#2d4a1e 45%,#4a7c2f 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '24px',
|
||||||
|
padding: '3rem',
|
||||||
|
maxWidth: '440px',
|
||||||
|
width: '100%',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Leaf size={32} color="white" />
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1a2810',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Selamat Datang
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#6a7a5a', fontSize: '0.9rem' }}>
|
||||||
|
Masuk ke akun JavaHerbal Anda
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#fee',
|
||||||
|
border: '1px solid #fcc',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
color: '#c33',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div style={{ marginBottom: '1.2rem' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#2d4a1e',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Mail
|
||||||
|
size={18}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: '#8a9a7a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="nama@email.com"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 12px 12px 44px',
|
||||||
|
border: '1px solid #e8eee0',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border 0.2s',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = '#7ab648')}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = '#e8eee0')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#2d4a1e',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Lock
|
||||||
|
size={18}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: '#8a9a7a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Masukkan password"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 44px 12px 44px',
|
||||||
|
border: '1px solid #e8eee0',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border 0.2s',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = '#7ab648')}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = '#e8eee0')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff size={18} color="#8a9a7a" />
|
||||||
|
) : (
|
||||||
|
<Eye size={18} color="#8a9a7a" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '14px',
|
||||||
|
background: loading
|
||||||
|
? '#ccc'
|
||||||
|
: 'linear-gradient(135deg,#4a7c2f,#7ab648)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: 'none',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Memproses...' : 'Masuk'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Register link */}
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<span style={{ color: '#6a7a5a', fontSize: '0.85rem' }}>
|
||||||
|
Belum punya akun?{' '}
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
style={{
|
||||||
|
color: '#4a7c2f',
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Daftar di sini
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Filter } from 'lucide-react'
|
||||||
|
import Footer from '../components/Footer'
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
price: string
|
||||||
|
unit: string
|
||||||
|
emoji: string
|
||||||
|
badge?: string
|
||||||
|
rating: number
|
||||||
|
reviews: number
|
||||||
|
bgColor: string
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_PRODUCTS: Product[] = [
|
||||||
|
{ id: 1, name: 'Cabai Jawa Kering Premium', description: 'Dikeringkan pada suhu presisi untuk mempertahankan warna dan aroma.', price: 'Rp 85.000', unit: '100g', emoji: '🌶️', badge: 'TERLARIS', rating: 4.9, reviews: 128, bgColor: 'linear-gradient(135deg,#8B1A0A,#C0392B)', category: 'Rempah' },
|
||||||
|
{ id: 2, name: 'Cabai Jawa', description: 'Proses higienis, warna dan rasa alami terjaga sepanjang proses pengeringan.', price: 'Rp 30.000', unit: '100g', emoji: '🌿', rating: 4.7, reviews: 84, bgColor: 'linear-gradient(135deg,#5c3a1e,#8b5e3c)', category: 'Rempah' },
|
||||||
|
{ id: 3, name: 'Teh Rempah Hangat', description: 'Campuran herbal pilihan untuk kesehatan yang menyegarkan jiwa dan raga.', price: 'Rp 55.000', unit: 'pack', emoji: '🍵', badge: 'BARU', rating: 4.8, reviews: 56, bgColor: 'linear-gradient(135deg,#4a3220,#7a5c3a)', category: 'Teh' },
|
||||||
|
{ id: 4, name: 'Jahe Merah Kering', description: 'Jahe merah pilihan kaya antioksidan, diproses tanpa bahan pengawet.', price: 'Rp 45.000', unit: '100g', emoji: '🫚', rating: 4.6, reviews: 72, bgColor: 'linear-gradient(135deg,#7a2010,#b84030)', category: 'Jamu' },
|
||||||
|
{ id: 5, name: 'Teh Jahe Wangi', description: 'Kombinasi sempurna jahe dan teh untuk menghangatkan tubuh.', price: 'Rp 48.000', unit: 'pack', emoji: '☕', rating: 4.8, reviews: 102, bgColor: 'linear-gradient(135deg,#6b4423,#9b6b3f)', category: 'Teh' },
|
||||||
|
{ id: 6, name: 'Kencur Bubuk', description: 'Kencur murni tanpa campuran, ideal untuk jamu dan minuman kesehatan.', price: 'Rp 42.000', unit: '100g', emoji: '🌾', rating: 4.7, reviews: 78, bgColor: 'linear-gradient(135deg,#7a5c2f,#a87d3f)', category: 'Jamu' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CATEGORIES = ['Semua', 'Rempah', 'Teh', 'Jamu']
|
||||||
|
|
||||||
|
function ProductCard({ product, index }: { product: Product; index: number }) {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '20px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: hovered ? '0 14px 44px rgba(45,74,30,0.15)' : '0 2px 20px rgba(45,74,30,0.06)',
|
||||||
|
border: '1px solid rgba(74,124,47,0.08)',
|
||||||
|
transform: hovered ? 'translateY(-6px)' : 'translateY(0)',
|
||||||
|
transition: 'transform 0.3s, box-shadow 0.3s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '175px',
|
||||||
|
background: product.bgColor,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '12px', left: '12px',
|
||||||
|
width: '26px', height: '26px',
|
||||||
|
background: 'rgba(0,0,0,0.28)',
|
||||||
|
borderRadius: '7px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: 'white', fontSize: '0.72rem', fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{product.id}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '3.8rem', filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.3))' }}>
|
||||||
|
{product.emoji}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '1.1rem' }}>
|
||||||
|
<h3 style={{ fontSize: '0.92rem', fontWeight: 700, color: '#1a2810', marginBottom: '5px', lineHeight: 1.3 }}>
|
||||||
|
{product.name}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.78rem', color: '#7a8a6a', lineHeight: 1.5, marginBottom: '1rem' }}>
|
||||||
|
{product.description}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: '1rem', fontWeight: 800, color: '#1a2810' }}>{product.price}</span>
|
||||||
|
<span style={{ fontSize: '0.74rem', color: '#a0a898' }}> / {product.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryBtn({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '7px 16px', borderRadius: '20px',
|
||||||
|
fontSize: '0.82rem', fontWeight: 600,
|
||||||
|
border: active ? 'none' : '1px solid #e8eee0',
|
||||||
|
background: active ? 'linear-gradient(135deg,#2d4a1e,#4a7c2f)' : 'white',
|
||||||
|
color: active ? 'white' : '#4a5c3a',
|
||||||
|
cursor: 'pointer', transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Products() {
|
||||||
|
const [activeCategory, setActiveCategory] = useState('Semua')
|
||||||
|
|
||||||
|
const filteredProducts = activeCategory === 'Semua'
|
||||||
|
? ALL_PRODUCTS
|
||||||
|
: ALL_PRODUCTS.filter(p => p.category === activeCategory)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ paddingTop: '64px', minHeight: '100vh', background: '#f5f2eb' }}>
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg,#1a2e0f 0%,#2d4a1e 45%,#4a7c2f 100%)',
|
||||||
|
padding: '4rem 2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: 'rgba(122,182,72,0.18)',
|
||||||
|
border: '1px solid rgba(122,182,72,0.38)',
|
||||||
|
borderRadius: '20px', padding: '6px 16px', marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#a8d878', fontSize: '0.8rem', fontWeight: 700, letterSpacing: '0.06em' }}>
|
||||||
|
KOLEKSI LENGKAP
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: 'clamp(2rem,4vw,3rem)',
|
||||||
|
fontWeight: 800,
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Produk Herbal Premium
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '1rem', lineHeight: 1.75 }}>
|
||||||
|
Rempah dan herbal Nusantara berkualitas tinggi, diproses dengan teknologi pengeringan
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Products Section */}
|
||||||
|
<section style={{ padding: '4rem 2rem' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
{/* Filter */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '2.5rem',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<Filter size={18} color="#4a7c2f" />
|
||||||
|
<span style={{ fontSize: '0.9rem', fontWeight: 600, color: '#2d4a1e' }}>
|
||||||
|
Filter Kategori:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<CategoryBtn
|
||||||
|
key={cat}
|
||||||
|
label={cat}
|
||||||
|
active={activeCategory === cat}
|
||||||
|
onClick={() => setActiveCategory(cat)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Count */}
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<span style={{ fontSize: '0.9rem', color: '#6a7a5a' }}>
|
||||||
|
Menampilkan {filteredProducts.length} produk
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill,minmax(270px,1fr))',
|
||||||
|
gap: '1.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredProducts.map((p, i) => (
|
||||||
|
<ProductCard key={p.id} product={p} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { Leaf, Mail, Lock, User, Eye, EyeOff } from 'lucide-react'
|
||||||
|
|
||||||
|
// API URL - Backend Railway
|
||||||
|
const API_URL = 'https://web-production-32384.up.railway.app/api'
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
// Validasi client-side
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Password tidak cocok')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('Password minimal 6 karakter')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Kirim request ke backend
|
||||||
|
const response = await fetch(`${API_URL}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name, email, password })
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('✅ Registrasi berhasil:', result.user.email)
|
||||||
|
alert('Registrasi berhasil! Silakan login.')
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
setError(result.message || 'Registrasi gagal')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error register:', error)
|
||||||
|
setError('Terjadi kesalahan. Pastikan backend sudah berjalan.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(135deg,#1a2e0f 0%,#2d4a1e 45%,#4a7c2f 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '24px',
|
||||||
|
padding: '3rem',
|
||||||
|
maxWidth: '440px',
|
||||||
|
width: '100%',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
background: 'linear-gradient(135deg,#7ab648,#4a7c2f)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Leaf size={32} color="white" />
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Playfair Display', serif",
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1a2810',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Buat Akun Baru
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#6a7a5a', fontSize: '0.9rem' }}>
|
||||||
|
Bergabung dengan JavaHerbal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#fee',
|
||||||
|
border: '1px solid #fcc',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
color: '#c33',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ marginBottom: '1.2rem' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#2d4a1e',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Nama Lengkap
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<User
|
||||||
|
size={18}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: '#8a9a7a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Nama Anda"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 12px 12px 44px',
|
||||||
|
border: '1px solid #e8eee0',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = '#7ab648')}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = '#e8eee0')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div style={{ marginBottom: '1.2rem' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#2d4a1e',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Mail
|
||||||
|
size={18}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: '#8a9a7a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="nama@email.com"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 12px 12px 44px',
|
||||||
|
border: '1px solid #e8eee0',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = '#7ab648')}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = '#e8eee0')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div style={{ marginBottom: '1.2rem' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#2d4a1e',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Lock
|
||||||
|
size={18}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: '#8a9a7a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Minimal 6 karakter"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 44px 12px 44px',
|
||||||
|
border: '1px solid #e8eee0',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = '#7ab648')}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = '#e8eee0')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} color="#8a9a7a" /> : <Eye size={18} color="#8a9a7a" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#2d4a1e',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Konfirmasi Password
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Lock
|
||||||
|
size={18}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: '#8a9a7a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Ulangi password"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 44px 12px 44px',
|
||||||
|
border: '1px solid #e8eee0',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = '#7ab648')}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = '#e8eee0')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '14px',
|
||||||
|
background: loading
|
||||||
|
? '#ccc'
|
||||||
|
: 'linear-gradient(135deg,#4a7c2f,#7ab648)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: 'none',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Memproses...' : 'Daftar'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Login link */}
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<span style={{ color: '#6a7a5a', fontSize: '0.85rem' }}>
|
||||||
|
Sudah punya akun?{' '}
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
style={{
|
||||||
|
color: '#4a7c2f',
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Masuk di sini
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": false
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"buildCommand": "npm run build",
|
||||||
|
"outputDirectory": "dist",
|
||||||
|
"framework": "vite",
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"destination": "/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue