Initial commit - Web Javaherbal dengan auth backend

This commit is contained in:
flems 2026-05-21 10:42:24 +07:00
commit 6e2c3382ac
30 changed files with 6343 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -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

301
DEPLOY_VERCEL.md Normal file
View File

@ -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

397
PENJELASAN_SISTEM_LOGIN.md Normal file
View File

@ -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!** 🔒

69
PUSH_TO_GITHUB.md Normal file
View File

@ -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! 🚀

31
README.md Normal file
View File

@ -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)

245
README_UPDATE.md Normal file
View File

@ -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!** 🌶️

88
UPDATE_HERO.md Normal file
View File

@ -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!

19
index.html Normal file
View File

@ -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>

2168
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -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"
}
}

36
src/App.tsx Normal file
View File

@ -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>
)
}

View File

@ -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>
)
}

195
src/components/Footer.tsx Normal file
View File

@ -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>
)
}

242
src/components/Hero.tsx Normal file
View File

@ -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>
)
}

150
src/components/Navbar.tsx Normal file
View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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}</>
}

34
src/index.css Normal file
View File

@ -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); }
}

13
src/main.tsx Normal file
View File

@ -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>
)

408
src/pages/About.tsx Normal file
View File

@ -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 />
</>
)
}

12
src/pages/Control.tsx Normal file
View File

@ -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>
)
}

309
src/pages/Dashboard.tsx Normal file
View File

@ -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 />
</>
)
}

14
src/pages/Home.tsx Normal file
View File

@ -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 />
</>
)
}

278
src/pages/Login.tsx Normal file
View File

@ -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>
)
}

211
src/pages/Products.tsx Normal file
View File

@ -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 />
</>
)
}

369
src/pages/Register.tsx Normal file
View File

@ -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>
)
}

20
tsconfig.json Normal file
View File

@ -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" }]
}

11
tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": false
},
"include": ["vite.config.ts"]
}

11
vercel.json Normal file
View File

@ -0,0 +1,11 @@
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"framework": "vite",
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}

6
vite.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})