chore(bookoomoo): scaffold Vite + React + Tailwind app from skeleton with routing, layout, pages, auth, API wrapper

This commit is contained in:
2025-08-10 01:31:32 +07:00
parent 9aa4484129
commit a409cbd17b
23 changed files with 548 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
VITE_N8N_BASE_URL=https://your-n8n.example

13
bookoomoo-app/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bookoomoo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
{
"name": "bookoomoo-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"lucide-react": "^0.469.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.14",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

48
bookoomoo-app/src/App.jsx Normal file
View File

@@ -0,0 +1,48 @@
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>
)
}

View File

@@ -0,0 +1,53 @@
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 />
}

View File

@@ -0,0 +1,14 @@
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>
}

View File

@@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Theme tokens */
:root {
--bg: 255 255 255;
--surface: 255 255 255;
--text: 17 24 39;
--primary: 255 115 0; /* orange */
--accent: 14 165 233; /* sky */
--muted: 100 116 139;
}
.dark {
--bg: 15 23 42;
--surface: 2 6 23;
--text: 241 245 249;
--primary: 255 140 66;
--accent: 56 189 248;
--muted: 148 163 184;
}
@layer base {
html { font-family: Inter, system-ui, ui-sans-serif, sans-serif; }
body { @apply bg-[rgb(var(--bg))] text-[rgb(var(--text))] antialiased; }
}
@layer components {
.btn { @apply inline-flex items-center justify-center rounded-xl px-4 py-2 font-medium transition; }
.btn-primary { @apply btn bg-[rgb(var(--primary))] text-white hover:brightness-110 active:brightness-90; }
.card { @apply rounded-2xl border bg-white p-4 shadow-sm; }
}

View File

@@ -0,0 +1,25 @@
export const API = {
BASE_URL: import.meta.env.VITE_N8N_BASE_URL || '',
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()
},
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) },
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) },
}

View File

@@ -0,0 +1,16 @@
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) }

View File

@@ -0,0 +1,17 @@
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>
)

View File

@@ -0,0 +1,10 @@
import React from 'react'
export default function CreateStoryPage(){
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Buat Cerita</h2>
<p className="mt-1 text-sm text-neutral-600">Form pembuatan cerita akan ditempatkan di sini.</p>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import React, { useEffect, useState } from 'react'
import StatusBadge from '../components/StatusBadge'
import { API } from '../lib/api'
import { ArrowRight, BookOpen, Gift, Printer, 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)
} catch (e) {
// fallback demo data
setSummary({
user: { name: 'Pengguna Demo', tokenBalance: 3 },
stories: [
{ id: 's1', title: 'Petualangan Kucing', lang: 'ID', theme: 'Hewan', createdAt: '2025-08-10', status: 'PDF Ready' },
],
orders: [
{ id: 'o1', type: 'Softcover A5', serial: 'INV-001', createdAt: '2025-08-09', status: 'In Production' },
],
donations: [
{ id: 'd1', beneficiary: 'PA Tunas Bangsa', createdAt: '2025-08-08' },
],
})
} 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-purple-600 text-white"><Gift className="h-5 w-5"/></div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{d.beneficiary || 'Penerima'}</div>
<div className="mt-0.5 text-xs text-neutral-500">{d.createdAt}</div>
</div>
</li>))}
</ul>
</Panel>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
export default function DonationsPage(){
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Donasi</h2>
<p className="mt-1 text-sm text-neutral-600">Tracking program Buy 1 Donate 1.</p>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
export default function DownloadsPage(){
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Unduh PDF</h2>
<p className="mt-1 text-sm text-neutral-600">Daftar PDF tersedia untuk diunduh.</p>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth'
export default function LoginPage(){
const { login } = useAuth()
const nav = useNavigate()
function onSubmit(e){
e.preventDefault()
const form = new FormData(e.currentTarget)
const name = form.get('name') || 'Pengguna'
login({ name, tokenBalance: 0 })
nav('/')
}
return (
<div className="grid min-h-screen place-items-center bg-neutral-50 p-4">
<form onSubmit={onSubmit} className="w-full max-w-sm space-y-4 rounded-2xl border bg-white p-5 shadow-sm">
<h1 className="text-xl font-semibold">Masuk</h1>
<label className="block text-sm">
<span className="mb-1 block text-neutral-700">Nama</span>
<input name="name" className="w-full rounded-xl border px-3 py-2 outline-none focus:ring-2 focus:ring-sky-300" placeholder="Nama kamu" />
</label>
<button className="w-full rounded-xl bg-neutral-900 px-4 py-2 text-white">Masuk</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
export default function OrdersPage(){
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Order</h2>
<p className="mt-1 text-sm text-neutral-600">Riwayat order dan status produksi.</p>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
export default function PrintCheckoutPage(){
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Cetak Fisik</h2>
<p className="mt-1 text-sm text-neutral-600">Pilih opsi cetak dan checkout.</p>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
export default function StoriesPage(){
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Cerita</h2>
<p className="mt-1 text-sm text-neutral-600">Koleksi cerita kamu.</p>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
export default function TopUpPage(){
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Top Up</h2>
<p className="mt-1 text-sm text-neutral-600">Isi saldo token untuk generate PDF.</p>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
export default function UploadPage(){
return (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Upload Foto</h2>
<p className="mt-1 text-sm text-neutral-600">Unggah foto untuk personalisasi cerita.</p>
</div>
)
}

View File

@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{js,jsx,ts,tsx}',
],
theme: {
extend: {
colors: {
bg: 'rgb(var(--bg))',
surface: 'rgb(var(--surface))',
text: 'rgb(var(--text))',
primary: 'rgb(var(--primary))',
accent: 'rgb(var(--accent))',
muted: 'rgb(var(--muted))',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'ui-sans-serif', 'sans-serif'],
display: ['Poppins', 'Inter', 'ui-sans-serif', 'sans-serif'],
},
},
},
darkMode: 'class',
plugins: [],
}

View File

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