718 lines
32 KiB
React
718 lines
32 KiB
React
|
|
// =============================
|
||
|
|
// 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; }
|