Files
karyaman-project/bookoomoo-app/bookoomoo_react_app_skeleton_dashboard_pages_api.jsx

718 lines
32 KiB
JavaScript

// =============================
// Vite + React + Tailwind App
// Dashboard functional + pages wired to n8n
// Files are concatenated here. When implementing, split into the paths shown.
// =============================
// ------------------------------------------
// File: src/main.jsx
// ------------------------------------------
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
import { AuthProvider } from './lib/auth'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</React.StrictMode>
)
// ------------------------------------------
// File: src/App.jsx
// ------------------------------------------
import React from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import Layout from './components/Layout'
import DashboardPage from './pages/DashboardPage'
import CreateStoryPage from './pages/CreateStoryPage'
import UploadPage from './pages/UploadPage'
import DownloadsPage from './pages/DownloadsPage'
import PrintCheckoutPage from './pages/PrintCheckoutPage'
import OrdersPage from './pages/OrdersPage'
import StoriesPage from './pages/StoriesPage'
import DonationsPage from './pages/DonationsPage'
import TopUpPage from './pages/TopUpPage'
import LoginPage from './pages/LoginPage'
import { useAuth } from './lib/auth'
function ProtectedRoute({ children }) {
const { user } = useAuth()
if (!user) return <Navigate to="/login" replace />
return children
}
export default function App(){
return (
<Routes>
<Route path="/login" element={<LoginPage/>} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage/>} />
<Route path="create" element={<CreateStoryPage/>} />
<Route path="upload" element={<UploadPage/>} />
<Route path="downloads" element={<DownloadsPage/>} />
<Route path="print" element={<PrintCheckoutPage/>} />
<Route path="orders" element={<OrdersPage/>} />
<Route path="stories" element={<StoriesPage/>} />
<Route path="donations" element={<DonationsPage/>} />
<Route path="topup" element={<TopUpPage/>} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
// ------------------------------------------
// File: src/lib/auth.jsx (very simple client auth state)
// ------------------------------------------
import React, { createContext, useContext, useEffect, useState } from 'react'
const AuthCtx = createContext(null)
export function AuthProvider({ children }){
const [user, setUser] = useState(null)
useEffect(()=>{
const saved = localStorage.getItem('bookoomoo:user')
if(saved) setUser(JSON.parse(saved))
},[])
const login = (payload)=>{ setUser(payload); localStorage.setItem('bookoomoo:user', JSON.stringify(payload)) }
const logout = ()=>{ setUser(null); localStorage.removeItem('bookoomoo:user') }
return <AuthCtx.Provider value={{user, login, logout}}>{children}</AuthCtx.Provider>
}
export function useAuth(){ return useContext(AuthCtx) }
// ------------------------------------------
// File: src/lib/api.js (n8n endpoints wrapper)
// ------------------------------------------
export const API = {
// Set your n8n base here (domain/webhook path). For local dev, use proxy.
BASE_URL: import.meta.env.VITE_N8N_BASE_URL || 'http://localhost:5678',
async get(path){
const res = await fetch(`${this.BASE_URL}${path}`)
if(!res.ok) throw new Error('Request failed')
return res.json()
},
async post(path, body){
const res = await fetch(`${this.BASE_URL}${path}`,{ method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) })
if(!res.ok) throw new Error('Request failed')
return res.json()
},
// ---- Bookoomoo specific (align with PRD) ----
me(){ return this.get('/webhook/user/me') },
dashboard(){ return this.get('/webhook/dashboard/summary') },
listStories(){ return this.get('/webhook/stories/list') },
listOrders(){ return this.get('/webhook/orders/list') },
listDonations(){ return this.get('/webhook/donations/list') },
topupToken(data){ return this.post('/webhook/token/topup', data) },
// Story flow
genStory(payload){ return this.post('/webhook/story/generate', payload) },
genPDF(payload){ return this.post('/webhook/pdf/generate', payload) },
createPrintOrder(payload){ return this.post('/webhook/print/order', payload) },
}
// ------------------------------------------
// File: src/components/Layout.jsx
// ------------------------------------------
import React from 'react'
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { BookOpen, Gift, Home, LogOut, Printer, Search, Upload, Wallet, Download, PlusCircle } from 'lucide-react'
import { useAuth } from '../lib/auth'
function Shell(){
const { logout } = useAuth()
const nav = useNavigate()
return (
<div className="min-h-screen bg-neutral-50 text-neutral-900">
<header className="sticky top-0 z-30 border-b bg-white/70 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center gap-3 px-4 py-3">
<Link to="/" className="flex items-center gap-2">
<div className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-600 text-white shadow">📚</div>
<div className="text-lg font-semibold tracking-tight">Bookoomoo</div>
</Link>
<div className="ml-auto hidden w-full max-w-md items-center gap-2 rounded-xl border bg-white px-3 py-2 shadow-sm md:flex">
<Search className="h-4 w-4 text-neutral-400" />
<input className="w-full bg-transparent text-sm outline-none placeholder:text-neutral-400" placeholder="Cari cerita, order, atau donasi…" />
</div>
<button onClick={()=>{ logout(); nav('/login') }} className="ml-2 inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50">
<LogOut className="h-4 w-4" /> Keluar
</button>
</div>
</header>
<nav className="mx-auto max-w-7xl px-4 pt-4">
<div className="flex flex-wrap gap-2">
<Link to="/" className="rounded-xl border bg-white px-3 py-1.5 text-sm hover:bg-neutral-50 inline-flex items-center gap-2"><Home className="h-4 w-4"/> Dashboard</Link>
<Link to="/create" className="rounded-xl border bg-white px-3 py-1.5 text-sm hover:bg-neutral-50 inline-flex items-center gap-2"><PlusCircle className="h-4 w-4"/> Buat Cerita</Link>
<Link to="/upload" className="rounded-xl border bg-white px-3 py-1.5 text-sm hover:bg-neutral-50 inline-flex items-center gap-2"><Upload className="h-4 w-4"/> Upload Foto</Link>
<Link to="/downloads" className="rounded-xl border bg-white px-3 py-1.5 text-sm hover:bg-neutral-50 inline-flex items-center gap-2"><Download className="h-4 w-4"/> Unduh PDF</Link>
<Link to="/print" className="rounded-xl border bg-white px-3 py-1.5 text-sm hover:bg-neutral-50 inline-flex items-center gap-2"><Printer className="h-4 w-4"/> Cetak Fisik</Link>
<Link to="/stories" className="rounded-xl border bg-white px-3 py-1.5 text-sm hover:bg-neutral-50 inline-flex items-center gap-2"><BookOpen className="h-4 w-4"/> Cerita</Link>
<Link to="/orders" className="rounded-xl border bg-white px-3 py-1.5 text-sm hover:bg-neutral-50 inline-flex items-center gap-2"><Printer className="h-4 w-4"/> Order</Link>
<Link to="/donations" className="rounded-xl border bg-white px-3 py-1.5 text-sm hover:bg-neutral-50 inline-flex items-center gap-2"><Gift className="h-4 w-4"/> Donasi</Link>
<Link to="/topup" className="rounded-xl border bg-white px-3 py-1.5 text-sm hover:bg-neutral-50 inline-flex items-center gap-2"><Wallet className="h-4 w-4"/> Top Up</Link>
</div>
</nav>
<main className="mx-auto max-w-7xl px-4 py-6">
<Outlet />
</main>
<footer className="mx-auto max-w-7xl px-4 py-10 text-center text-xs text-neutral-500">© {new Date().getFullYear()} Bookoomoo Be Different, Be You.</footer>
</div>
)
}
export default function Layout(){
return <Shell />
}
// ------------------------------------------
// File: src/components/StatusBadge.jsx
// ------------------------------------------
import React from 'react'
export default function StatusBadge({ status }){
const map = {
Draft: 'bg-neutral-100 text-neutral-700',
'PDF Ready': 'bg-blue-100 text-blue-700',
Printed: 'bg-emerald-100 text-emerald-700',
Completed: 'bg-emerald-100 text-emerald-700',
'In Production': 'bg-amber-100 text-amber-700',
Delivered: 'bg-purple-100 text-purple-700',
}
const cls = map[status] || 'bg-neutral-100 text-neutral-700'
return <span className={`inline-flex items-center rounded-xl px-2.5 py-1 text-xs font-medium ${cls}`}>{status}</span>
}
// ------------------------------------------
// File: src/pages/DashboardPage.jsx
// (Fetches real data from n8n)
// ------------------------------------------
import React, { useEffect, useState } from 'react'
import StatusBadge from '../components/StatusBadge'
import { API } from '../lib/api'
import { ArrowRight, BookOpen, Gift, Printer, RefreshCw, Wallet, Download, PlusCircle, Upload } from 'lucide-react'
function Card({ children }){ return <div className="rounded-2xl border bg-white p-4 shadow-sm">{children}</div> }
function Panel({ title, actionLabel, actionHref, right, children }){
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center gap-3">
<h3 className="text-base font-semibold">{title}</h3>
<div className="ml-auto flex items-center gap-3">
{right || null}
{actionLabel && actionHref && (
<a href={actionHref} className="inline-flex items-center gap-1 rounded-xl border px-3 py-1.5 text-sm hover:bg-neutral-50">{actionLabel} <ArrowRight className="h-4 w-4"/></a>
)}
</div>
</div>
{children}
</div>
)
}
function QuickAction({ icon, title, desc, href='#' }){
return (
<a href={href} className="group rounded-2xl border bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-neutral-900 text-white group-hover:shadow">{icon}</div>
<div>
<div className="font-medium">{title}</div>
<div className="mt-1 text-sm text-neutral-600">{desc}</div>
</div>
</div>
</a>
)
}
export default function DashboardPage(){
const [loading, setLoading] = useState(true)
const [summary, setSummary] = useState({ user:{name:'—', tokenBalance:0}, stories:[], orders:[], donations:[] })
async function load(){
setLoading(true)
try{
const data = await API.dashboard()
setSummary(data)
} finally { setLoading(false) }
}
useEffect(()=>{ load() },[])
const { user, stories, orders, donations } = summary
return (
<div>
<section className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold leading-tight md:text-3xl">{user?.name || 'Halo, Pengguna'}</h1>
<p className="mt-1 text-sm text-neutral-600">Buat buku cerita personal, unduh PDF, atau cetak fisik dengan program <span className="font-medium">Buy 1 Donate 1</span>.</p>
</div>
<div className="flex flex-wrap gap-2">
<a href="/create" className="inline-flex items-center gap-2 rounded-2xl bg-neutral-900 px-4 py-2 text-white shadow hover:bg-neutral-800"><PlusCircle className="h-4 w-4"/>Buat Cerita Baru</a>
<a href="/topup" className="inline-flex items-center gap-2 rounded-2xl border px-4 py-2 hover:bg-neutral-50"><Wallet className="h-4 w-4"/>Top Up Token</a>
</div>
</section>
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<div className="flex items-center justify-between"><span className="text-sm text-neutral-500">Saldo Token</span><Wallet className="h-4 w-4 text-neutral-400"/></div>
<div className="mt-3 text-3xl font-bold">{user?.tokenBalance ?? 0}</div>
<div className="mt-2 text-xs text-neutral-500">1 Token = 1x Generate PDF</div>
</Card>
<Card>
<div className="flex items-center justify-between"><span className="text-sm text-neutral-500">Cerita Selesai</span><BookOpen className="h-4 w-4 text-neutral-400"/></div>
<div className="mt-3 text-3xl font-bold">{stories?.length ?? 0}</div>
<div className="mt-2 text-xs text-neutral-500">Total semua waktu</div>
</Card>
<Card>
<div className="flex items-center justify-between"><span className="text-sm text-neutral-500">Order Cetak Aktif</span><Printer className="h-4 w-4 text-neutral-400"/></div>
<div className="mt-3 text-3xl font-bold">{orders?.filter(o=>o.status!=='Completed').length ?? 0}</div>
<div className="mt-2 text-xs text-neutral-500">Sedang diproses</div>
</Card>
<Card>
<div className="flex items-center justify-between"><span className="text-sm text-neutral-500">Donasi Tersalurkan</span><Gift className="h-4 w-4 text-neutral-400"/></div>
<div className="mt-3 text-3xl font-bold">{donations?.length ?? 0}</div>
<div className="mt-2 text-xs text-neutral-500">Buy 1 Donate 1</div>
</Card>
</section>
<section className="mt-6 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<QuickAction icon={<PlusCircle className="h-5 w-5"/>} title="Buat Cerita" desc="Masukkan nama anak & pilih tema" href="/create"/>
<QuickAction icon={<Upload className="h-5 w-5"/>} title="Upload Foto" desc="Opsional untuk personalisasi" href="/upload"/>
<QuickAction icon={<Download className="h-5 w-5"/>} title="Unduh PDF" desc="Gunakan token kamu" href="/downloads"/>
<QuickAction icon={<Printer className="h-5 w-5"/>} title="Cetak Fisik" desc="Buy 1 Donate 1" href="/print"/>
</section>
<section className="mt-6 grid gap-6 lg:grid-cols-12">
<div className="lg:col-span-7">
<Panel title="Cerita Terbaru" actionLabel="Lihat Semua" actionHref="/stories">
<ul className="divide-y">{(stories||[]).slice(0,5).map((s)=>(
<li key={s.id} className="flex items-center gap-3 py-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-sky-500 to-blue-600 text-white"><BookOpen className="h-5 w-5"/></div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{s.title}</div>
<div className="mt-0.5 text-xs text-neutral-500">{s.lang} {s.theme} {s.createdAt}</div>
</div>
<StatusBadge status={s.status}/>
<a href={`/stories`} className="ml-2 inline-flex items-center gap-1 text-sm text-neutral-600 hover:text-neutral-900">Buka <ArrowRight className="h-4 w-4"/></a>
</li>))}
</ul>
</Panel>
</div>
<div className="lg:col-span-5 space-y-6">
<Panel title="Order Terakhir" actionLabel="Riwayat Order" actionHref="/orders">
<ul className="divide-y">{(orders||[]).slice(0,5).map((o)=>(
<li key={o.id} className="flex items-center gap-3 py-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-emerald-500 to-green-600 text-white"><Printer className="h-5 w-5"/></div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{o.type}</div>
<div className="mt-0.5 text-xs text-neutral-500">{o.serial} {o.createdAt}</div>
</div>
<StatusBadge status={o.status}/>
</li>))}
</ul>
</Panel>
<Panel title="Tracking Donasi" actionLabel="Lihat Semua" actionHref="/donations">
<ul className="divide-y">{(donations||[]).slice(0,5).map((d)=>(
<li key={d.id} className="flex items-center gap-3 py-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-fuchsia-500 to-pink-600 text-white"><Gift className="h-5 w-5"/></div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{d.recipient}</div>
<div className="mt-0.5 text-xs text-neutral-500">{d.serial} Update {d.updatedAt}</div>
</div>
<StatusBadge status={d.status}/>
</li>))}
</ul>
</Panel>
</div>
</section>
<section className="mt-6 grid gap-6 lg:grid-cols-12">
<div className="lg:col-span-7">
<Panel title="Catatan & Tips">
<ul className="list-disc space-y-2 pl-5 text-sm text-neutral-700">
<li>Gunakan token untuk mengunduh PDF. Top up kapan saja lewat menu <span className="font-medium">Top Up Token</span>.</li>
<li>Untuk cetak fisik, pastikan alamat lengkap dan nomor telepon aktif.</li>
<li>Tracking donasi tersedia melalui QR di halaman buku donasi.</li>
</ul>
</Panel>
</div>
<div className="lg:col-span-5">
<Panel title="Sinkronisasi" right={<button onClick={load} className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"><RefreshCw className="h-4 w-4"/> Muat Ulang Data</button>}>
<div className="text-sm text-neutral-700">Status layanan n8n & storage tampak normal. Jika ada kendala, klik tombol untuk menyegarkan data.</div>
</Panel>
</div>
</section>
</div>
)
}
// ------------------------------------------
// File: src/pages/CreateStoryPage.jsx
// ------------------------------------------
import React, { useState } from 'react'
import { API } from '../lib/api'
export default function CreateStoryPage(){
const [form, setForm] = useState({ childName:'', theme:'Adventure', lang:'ID/EN', illustration:'Auto' })
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
async function submit(e){
e.preventDefault(); setLoading(true); setResult(null)
try{
const story = await API.genStory(form)
setResult(story)
} finally { setLoading(false) }
}
async function toPDF(){
setLoading(true)
try{
const pdf = await API.genPDF({ storyId: result.storyId })
setResult(prev=>({...prev, pdf}))
} finally { setLoading(false) }
}
return (
<div className="max-w-2xl">
<h2 className="text-xl font-semibold mb-3">Buat Cerita</h2>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="text-sm">Nama Anak</label>
<input value={form.childName} onChange={e=>setForm({...form, childName:e.target.value})} className="mt-1 w-full rounded-xl border bg-white px-3 py-2" required/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm">Tema</label>
<select value={form.theme} onChange={e=>setForm({...form, theme:e.target.value})} className="mt-1 w-full rounded-xl border bg-white px-3 py-2">
<option>Adventure</option>
<option>Fantasy</option>
<option>Ocean</option>
<option>Space</option>
</select>
</div>
<div>
<label className="text-sm">Bahasa</label>
<select value={form.lang} onChange={e=>setForm({...form, lang:e.target.value})} className="mt-1 w-full rounded-xl border bg-white px-3 py-2">
<option>ID/EN</option>
<option>ID</option>
<option>EN</option>
</select>
</div>
</div>
<div>
<label className="text-sm">Ilustrasi</label>
<select value={form.illustration} onChange={e=>setForm({...form, illustration:e.target.value})} className="mt-1 w-full rounded-xl border bg-white px-3 py-2">
<option>Auto</option>
<option>Watercolor</option>
<option>Pencil</option>
<option>3D</option>
</select>
</div>
<button disabled={loading} className="rounded-xl bg-neutral-900 px-4 py-2 text-white">{loading? 'Memproses…':'Generate Cerita'}</button>
</form>
{result && (
<div className="mt-6 rounded-2xl border bg-white p-4">
<div className="font-medium">{result.title}</div>
<pre className="mt-2 whitespace-pre-wrap text-sm text-neutral-700">{JSON.stringify(result.content_json, null, 2)}</pre>
<div className="mt-3 flex gap-2">
<button onClick={toPDF} disabled={loading} className="rounded-xl border px-3 py-2">Generate PDF</button>
{result.pdf?.pdfUrl && <a className="rounded-xl bg-neutral-900 px-3 py-2 text-white" href={result.pdf.pdfUrl} target="_blank">Buka PDF</a>}
</div>
</div>
)}
</div>
)
}
// ------------------------------------------
// File: src/pages/UploadPage.jsx
// ------------------------------------------
import React, { useState } from 'react'
export default function UploadPage(){
const [file, setFile] = useState(null)
function submit(e){ e.preventDefault(); alert('Upload via Signed URL n8n (implement nanti)') }
return (
<div className="max-w-xl">
<h2 className="text-xl font-semibold mb-3">Upload Foto (Opsional)</h2>
<form onSubmit={submit} className="space-y-3">
<input type="file" accept="image/*" onChange={e=>setFile(e.target.files?.[0])} className="w-full rounded-xl border bg-white px-3 py-2"/>
<button className="rounded-xl bg-neutral-900 px-4 py-2 text-white">Upload</button>
</form>
{file && <p className="mt-2 text-sm">File dipilih: {file.name}</p>}
</div>
)
}
// ------------------------------------------
// File: src/pages/DownloadsPage.jsx
// ------------------------------------------
import React, { useEffect, useState } from 'react'
import { API } from '../lib/api'
export default function DownloadsPage(){
const [stories, setStories] = useState([])
useEffect(()=>{ API.listStories().then(r=>setStories(r)) },[])
return (
<div>
<h2 className="text-xl font-semibold mb-3">Unduh PDF</h2>
<table className="w-full text-sm">
<thead><tr className="text-left text-neutral-500"><th className="py-2">Judul</th><th>Serial</th><th>Status</th><th>Aksi</th></tr></thead>
<tbody>
{stories.map(s=> (
<tr key={s.id} className="border-t">
<td className="py-2">{s.title}</td>
<td>{s.serial || '—'}</td>
<td>{s.status}</td>
<td>
{s.pdfUrl ? (
<a className="rounded-xl bg-neutral-900 px-3 py-1.5 text-white" href={s.pdfUrl} target="_blank">Download</a>
) : (
<button onClick={async()=>{ await API.genPDF({ storyId:s.id }); location.reload() }} className="rounded-xl border px-3 py-1.5">Generate PDF (1 Token)</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ------------------------------------------
// File: src/pages/PrintCheckoutPage.jsx
// ------------------------------------------
import React, { useState } from 'react'
import { API } from '../lib/api'
export default function PrintCheckoutPage(){
const [form, setForm] = useState({ storyId:'', name:'', phone:'', address:'', donateRecipient:'' })
const [result, setResult] = useState(null)
async function submit(e){
e.preventDefault();
const r = await API.createPrintOrder(form)
setResult(r)
}
return (
<div className="max-w-2xl">
<h2 className="text-xl font-semibold mb-3">Cetak Fisik Buy 1 Donate 1</h2>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="text-sm">Story ID</label>
<input value={form.storyId} onChange={e=>setForm({...form, storyId:e.target.value})} className="mt-1 w-full rounded-xl border bg-white px-3 py-2" required/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm">Nama</label>
<input value={form.name} onChange={e=>setForm({...form, name:e.target.value})} className="mt-1 w-full rounded-xl border bg-white px-3 py-2" required/>
</div>
<div>
<label className="text-sm">No. HP</label>
<input value={form.phone} onChange={e=>setForm({...form, phone:e.target.value})} className="mt-1 w-full rounded-xl border bg-white px-3 py-2" required/>
</div>
</div>
<div>
<label className="text-sm">Alamat</label>
<textarea value={form.address} onChange={e=>setForm({...form, address:e.target.value})} className="mt-1 w-full rounded-xl border bg-white px-3 py-2" rows={3} required></textarea>
</div>
<div>
<label className="text-sm">Penerima Donasi (opsional)</label>
<input value={form.donateRecipient} onChange={e=>setForm({...form, donateRecipient:e.target.value})} className="mt-1 w-full rounded-xl border bg-white px-3 py-2"/>
</div>
<button className="rounded-xl bg-neutral-900 px-4 py-2 text-white">Buat Order Cetak</button>
</form>
{result && (
<div className="mt-4 rounded-2xl border bg-white p-4 text-sm">
<div>Order ID: <b>{result.orderId}</b></div>
<div>Serial Pembeli: <b>{result.serial_1}</b></div>
<div>Serial Donasi: <b>{result.serial_2}</b></div>
<div>Status: <b>{result.status}</b></div>
</div>
)}
</div>
)
}
// ------------------------------------------
// File: src/pages/OrdersPage.jsx
// ------------------------------------------
import React, { useEffect, useState } from 'react'
import { API } from '../lib/api'
import StatusBadge from '../components/StatusBadge'
export default function OrdersPage(){
const [orders, setOrders] = useState([])
useEffect(()=>{ API.listOrders().then(setOrders) },[])
return (
<div>
<h2 className="text-xl font-semibold mb-3">Riwayat Order</h2>
<table className="w-full text-sm">
<thead><tr className="text-left text-neutral-500"><th className="py-2">Serial</th><th>Jenis</th><th>Status</th><th>Tanggal</th></tr></thead>
<tbody>
{orders.map(o=> (
<tr key={o.id} className="border-t">
<td className="py-2">{o.serial}</td>
<td>{o.type}</td>
<td><StatusBadge status={o.status}/></td>
<td>{o.createdAt}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ------------------------------------------
// File: src/pages/StoriesPage.jsx
// ------------------------------------------
import React, { useEffect, useState } from 'react'
import { API } from '../lib/api'
import StatusBadge from '../components/StatusBadge'
export default function StoriesPage(){
const [stories, setStories] = useState([])
useEffect(()=>{ API.listStories().then(setStories) },[])
return (
<div>
<h2 className="text-xl font-semibold mb-3">Cerita Saya</h2>
<table className="w-full text-sm">
<thead><tr className="text-left text-neutral-500"><th className="py-2">Judul</th><th>Tema</th><th>Bahasa</th><th>Status</th><th>Created</th></tr></thead>
<tbody>
{stories.map(s=> (
<tr key={s.id} className="border-t">
<td className="py-2">{s.title}</td>
<td>{s.theme}</td>
<td>{s.lang}</td>
<td><StatusBadge status={s.status}/></td>
<td>{s.createdAt}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ------------------------------------------
// File: src/pages/DonationsPage.jsx
// ------------------------------------------
import React, { useEffect, useState } from 'react'
import { API } from '../lib/api'
import StatusBadge from '../components/StatusBadge'
export default function DonationsPage(){
const [donations, setDonations] = useState([])
useEffect(()=>{ API.listDonations().then(setDonations) },[])
return (
<div>
<h2 className="text-xl font-semibold mb-3">Tracking Donasi</h2>
<table className="w-full text-sm">
<thead><tr className="text-left text-neutral-500"><th className="py-2">Serial</th><th>Penerima</th><th>Status</th><th>Update</th><th>QR</th></tr></thead>
<tbody>
{donations.map(d=> (
<tr key={d.id} className="border-t">
<td className="py-2">{d.serial}</td>
<td>{d.recipient}</td>
<td><StatusBadge status={d.status}/></td>
<td>{d.updatedAt}</td>
<td>{d.qrUrl ? <a className="underline" href={d.qrUrl} target="_blank">Buka</a> : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ------------------------------------------
// File: src/pages/TopUpPage.jsx
// ------------------------------------------
import React, { useState } from 'react'
import { API } from '../lib/api'
export default function TopUpPage(){
const [amount, setAmount] = useState(1)
const [result, setResult] = useState(null)
async function submit(e){ e.preventDefault(); const r = await API.topupToken({ tokens: amount }); setResult(r) }
return (
<div className="max-w-md">
<h2 className="text-xl font-semibold mb-3">Top Up Token</h2>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="text-sm">Jumlah Token</label>
<input type="number" min={1} value={amount} onChange={e=>setAmount(parseInt(e.target.value||'1'))} className="mt-1 w-full rounded-xl border bg-white px-3 py-2"/>
</div>
<button className="rounded-xl bg-neutral-900 px-4 py-2 text-white">Buat Invoice</button>
</form>
{result && (
<div className="mt-4 rounded-2xl border bg-white p-4 text-sm">
<div>Invoice: <b>{result.invoiceId}</b></div>
<div>Metode: <b>{result.method}</b></div>
<div>Total: <b>{result.total}</b></div>
{result.qrisUrl && <a className="mt-2 inline-block rounded-xl bg-neutral-900 px-3 py-2 text-white" href={result.qrisUrl} target="_blank">Bayar via QRIS</a>}
</div>
)}
</div>
)
}
// ------------------------------------------
// File: src/pages/LoginPage.jsx
// ------------------------------------------
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth'
export default function LoginPage(){
const nav = useNavigate();
const { login } = useAuth()
const [email, setEmail] = useState('demo@bookoomoo.app')
const [pass, setPass] = useState('demo')
function submit(e){ e.preventDefault(); login({ id:1, name:'Demo User', email, tokenBalance: 3 }); nav('/') }
return (
<div className="min-h-screen grid place-items-center bg-neutral-50">
<form onSubmit={submit} className="w-full max-w-sm rounded-2xl border bg-white p-6 shadow-sm">
<div className="text-lg font-semibold">Masuk ke Bookoomoo</div>
<div className="mt-4">
<label className="text-sm">Email</label>
<input className="mt-1 w-full rounded-xl border bg-white px-3 py-2" value={email} onChange={e=>setEmail(e.target.value)} />
</div>
<div className="mt-3">
<label className="text-sm">Password</label>
<input type="password" className="mt-1 w-full rounded-xl border bg-white px-3 py-2" value={pass} onChange={e=>setPass(e.target.value)} />
</div>
<button className="mt-4 w-full rounded-xl bg-neutral-900 px-4 py-2 text-white">Masuk</button>
</form>
</div>
)
}
// ------------------------------------------
// File: src/index.css (Tailwind base)
// ------------------------------------------
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Optional: smooth fonts */
html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }