feat(auth,docs): add AGENTS guidelines, consistency kit, particle background, solid card, logo swap, header toggles, signup password rules, and phone input border fix
This commit is contained in:
10
.editorconfig
Normal file
10
.editorconfig
Normal file
@@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
50
.github/ISSUE_TEMPLATE/ai-task.yml
vendored
Normal file
50
.github/ISSUE_TEMPLATE/ai-task.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: AI Task
|
||||
description: Brief for the coding agent to implement features consistently
|
||||
title: "[AI] <short title>"
|
||||
labels: [ai-task]
|
||||
body:
|
||||
- type: input
|
||||
id: project_path
|
||||
attributes:
|
||||
label: Project path
|
||||
description: Path of the target app (e.g., kediritechnopark-app)
|
||||
placeholder: kediritechnopark-app
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: objective
|
||||
attributes:
|
||||
label: Objective
|
||||
description: What to build and why
|
||||
placeholder: e.g., Build pricing card with tiers and CTA
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: requirements
|
||||
attributes:
|
||||
label: Requirements
|
||||
description: States, a11y, tests, performance, theming, i18n
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: deliverables
|
||||
attributes:
|
||||
label: Deliverables
|
||||
description: Files and paths to create or modify
|
||||
placeholder: |
|
||||
src/components/PricingCard/PricingCard.tsx
|
||||
src/components/PricingCard/types.ts
|
||||
src/components/PricingCard/PricingCard.test.tsx
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: constraints
|
||||
attributes:
|
||||
label: Constraints
|
||||
description: Perf budget, API contracts, dependency limits
|
||||
- type: textarea
|
||||
id: acceptance
|
||||
attributes:
|
||||
label: Definition of Done
|
||||
description: Validation criteria (build/lint/tests pass, coverage, screenshots)
|
||||
|
||||
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
## Summary
|
||||
Closes #
|
||||
|
||||
## Scope
|
||||
- What/why in 1–2 sentences
|
||||
- Affected areas (files, routes, components)
|
||||
|
||||
## Screenshots / Videos
|
||||
If UI changed, include before/after and dark mode.
|
||||
|
||||
## Checklist
|
||||
- [ ] Build OK
|
||||
- [ ] Lint OK
|
||||
- [ ] Tests added/updated and passing
|
||||
- [ ] A11y checked (labels, roles, keyboard, focus)
|
||||
- [ ] Handles states (loading/empty/error)
|
||||
- [ ] Dark mode verified
|
||||
- [ ] Docs updated (AGENTS.md or component README where relevant)
|
||||
|
||||
42
AGENTS.md
Normal file
42
AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Monorepo with multiple Vite apps at the root (e.g., `kediritechnopark-app/`, `adventure-rental-app/`, `motel-app/`).
|
||||
- Each app is self‑contained: `src/` (code), `public/` (static), `index.html`, `tailwind.config.js`, `vite.config.ts|js`.
|
||||
- Inside `src/`: prefer `components/`, `pages/`, `hooks/`, `lib/`, `assets/`, `styles/`. Shared code should be copied per app unless a dedicated shared package exists.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Run these from the target app directory:
|
||||
- `npm install`: install dependencies.
|
||||
- `npm run dev`: start Vite dev server.
|
||||
- `npm run build`: production build.
|
||||
- `npm run preview`: preview build locally.
|
||||
- `npm run lint` / `npm run format`: lint and format (if configured).
|
||||
- `npm test`: run unit tests (Vitest) when present.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript, React function components; 2‑space indent.
|
||||
- Files: components `PascalCase.tsx`, hooks `useThing.ts`, utilities `camelCase.ts`, test files `*.test.ts(x)`.
|
||||
- Styling: Tailwind CSS utilities; theme via CSS variables; avoid inline styles except for dynamic values.
|
||||
- Tools: ESLint (TypeScript/React), Prettier. Fix issues before committing.
|
||||
|
||||
## Testing Guidelines
|
||||
- Frameworks: Vitest + @testing-library/react for unit/integration; Playwright optional for e2e.
|
||||
- Location: co‑locate as `Component.test.tsx` next to the file or under `src/__tests__/`.
|
||||
- Coverage: target ≥80% statements/branches on changed code.
|
||||
- Commands: `npm test` for watch, `npm test -- --coverage` for reports.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Use Conventional Commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, etc. Example: `feat(cards): add skeleton and a11y labels`.
|
||||
- PRs: include a clear description, linked issues (e.g., `Closes #123`), screenshots for UI changes, and test notes.
|
||||
- CI passes required: build, lint, tests. Keep PRs small and focused.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Never commit secrets. Use per‑app `.env.local` with Vite `VITE_` prefixes (e.g., `VITE_API_URL`).
|
||||
- Update dependencies responsibly; prefer minor/patch bumps unless necessary.
|
||||
- Follow a11y best practices (labels, roles, keyboard support) and avoid introducing regressions.
|
||||
|
||||
## Agent-Specific Instructions
|
||||
- Read `docs/AGENT_CONTEXT.md` for the full context (identity, objectives, stack, output contract).
|
||||
- Create work items using the GitHub “AI Task” issue template.
|
||||
- Ensure PRs meet the checklist in `.github/PULL_REQUEST_TEMPLATE.md`.
|
||||
@@ -0,0 +1,717 @@
|
||||
// =============================
|
||||
// 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; }
|
||||
35
docs/AGENT_CONTEXT.md
Normal file
35
docs/AGENT_CONTEXT.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Agent Context
|
||||
|
||||
This repository uses a consistent product-engineering standard across apps.
|
||||
|
||||
## Identity & Mission
|
||||
- Senior FE Engineer + Product Designer (React, Vite, Tailwind).
|
||||
- Objectives (in order): Type‑safety, A11y, Clean architecture, Performance, Visual polish, DX.
|
||||
|
||||
## Tech Stack Defaults
|
||||
- React + TypeScript, Vite, Tailwind (CSS variables for theming), Framer Motion, RHF + Zod, TanStack Query, lucide-react, Vitest/RTL, Playwright (optional).
|
||||
|
||||
## Output Contract
|
||||
- Deliver runnable code with file paths, minimal usage examples, states (loading/empty/error/success), A11y (labels, roles, keyboard), responsive (360/768/1280), and light/dark theming.
|
||||
|
||||
## Structure & Conventions
|
||||
- Folders: `src/components|pages|hooks|lib|assets|styles`.
|
||||
- Names: Components `PascalCase.tsx`, hooks `useThing.ts`, utils `camelCase.ts`, tests `*.test.ts(x)`.
|
||||
- Styling: Tailwind utilities; theme via `:root`/`.dark` CSS variables; avoid heavy deps.
|
||||
|
||||
## Visual Language
|
||||
- Typography: Poppins (headings), Inter/Roboto (body). Accent: orange. Subtle shadows/glass. Motion 160–300ms, respect reduced‑motion.
|
||||
|
||||
## Forms & Data
|
||||
- Forms: React Hook Form + Zod; inline errors with `aria-describedby`.
|
||||
- Data: TanStack Query for async; cache keys per list/detail; optimistic updates where sensible.
|
||||
|
||||
## Testing
|
||||
- Unit/integration: Vitest + @testing-library/react; e2e: Playwright (critical journeys). Target ≥80% coverage on changed code.
|
||||
|
||||
## Security & Config
|
||||
- No secrets in git. Use `.env.local` with `VITE_` prefixes. Sanitize inputs; prefer HTTP‑only cookies for auth when applicable.
|
||||
|
||||
## How to Brief the Agent
|
||||
- Open an issue using “AI Task” template. Provide: app path (e.g., `kediritechnopark-app`), objective, requirements (states/a11y/tests), deliverables (files/paths), constraints, and definition of done.
|
||||
|
||||
241
docs/GEMINI.md
Normal file
241
docs/GEMINI.md
Normal file
@@ -0,0 +1,241 @@
|
||||
## 1) Identity & Mission
|
||||
|
||||
* **Identity**: Senior Frontend Engineer + Product Designer hybrid with expertise in **React, Vite, Tailwind**, and refined **UI/UX**. Combines engineering rigor with an artful visual sense.
|
||||
* **Mission**: Deliver *production‑ready*, accessible, and well‑documented React components/pages with consistent patterns and tasteful aesthetics.
|
||||
|
||||
---
|
||||
|
||||
## 2) Primary Objectives (in order)
|
||||
|
||||
1. **Correctness & Type‑safety** (TypeScript by default)
|
||||
2. **Accessibility (WCAG 2.2 AA)** & keyboard support
|
||||
3. **Clean Architecture & Reusability**
|
||||
4. **Performance & Bundle Hygiene**
|
||||
5. **Visual Polish & Micro‑interactions**
|
||||
6. **DX: clear comments, minimal setup friction**
|
||||
|
||||
---
|
||||
|
||||
## 3) Non‑Goals / Guardrails
|
||||
|
||||
* Do **not** introduce heavy dependencies unless justified.
|
||||
* Do **not** produce pseudo‑code. Always runnable snippets or clear file diffs.
|
||||
* Do **not** ignore error/empty/loading states.
|
||||
* Do **not** ship unactionable placeholders (e.g., `TODO` without guidance).
|
||||
|
||||
---
|
||||
|
||||
## 4) Tech Stack Defaults
|
||||
|
||||
* **React + Vite + TypeScript**
|
||||
* **Tailwind CSS** (utility‑first) with CSS variables for theming
|
||||
* **Framer Motion** for motion
|
||||
* **React Hook Form + Zod** for forms & validation
|
||||
* **TanStack Query** for server state (when async data present)
|
||||
* **lucide-react** for icons; optional shadcn/ui for primitives
|
||||
* **Vitest + @testing-library/react** for unit/integration; **Playwright** for e2e
|
||||
|
||||
> If the user’s request conflicts with defaults, adapt but state the trade‑offs.
|
||||
|
||||
---
|
||||
|
||||
## 5) Output Contract (Always Follow)
|
||||
|
||||
* **Deliver runnable code** with file paths (e.g., `src/components/...`), and any required `tailwind.config.js`/`index.css` changes.
|
||||
* **Include minimal usage example** for each component.
|
||||
* **Document props** (TS types) and behavior in concise comments.
|
||||
* **Handle states**: loading, empty, error, success.
|
||||
* **Accessibility**: proper roles, labels, focus management, ARIA where needed.
|
||||
* **Responsive**: mobile‑first; verify at 360px, 768px, 1280px.
|
||||
* **Theming**: light & dark out of the box via CSS variables or `.dark` class.
|
||||
|
||||
---
|
||||
|
||||
## 6) Visual Language
|
||||
|
||||
* **Typography**: Poppins for headings, Inter/Roboto for body.
|
||||
* **Accents**: tasteful orange primary (customizable), subtle glows/shadows.
|
||||
* **Surfaces**: soft glassmorphism (blur/opacity) used sparingly.
|
||||
* **Motion**: 160–300ms ease; respect `prefers-reduced-motion`.
|
||||
* **Layout**: grid‑first, consistent spacing rhythm.
|
||||
|
||||
---
|
||||
|
||||
## 7) Tailwind Tokens (CSS Variables)
|
||||
|
||||
Define tokens in `:root` and `.dark`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: 255 255 255; /* base background */
|
||||
--surface: 248 250 252; /* panels/cards */
|
||||
--text: 17 24 39; /* primary text */
|
||||
--primary: 255 115 0; /* orange accent */
|
||||
--accent: 14 165 233; /* supporting accent */
|
||||
--muted: 100 116 139; /* secondary text */
|
||||
}
|
||||
.dark {
|
||||
--bg: 2 6 23;
|
||||
--surface: 15 23 42;
|
||||
--text: 241 245 249;
|
||||
--primary: 255 140 66;
|
||||
--accent: 56 189 248;
|
||||
--muted: 148 163 184;
|
||||
}
|
||||
```
|
||||
|
||||
Utility classes:
|
||||
|
||||
```css
|
||||
@tailwind base; @tailwind components; @tailwind utilities;
|
||||
@layer base { body { @apply bg-[rgb(var(--bg))] text-[rgb(var(--text))] antialiased; } }
|
||||
@layer components {
|
||||
.card { @apply rounded-2xl bg-[rgb(var(--surface))]/80 backdrop-blur-xl shadow-lg; }
|
||||
.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; }
|
||||
.input { @apply w-full rounded-xl border border-slate-200/60 bg-white/60 px-3 py-2 outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Component Blueprint (apply to all components)
|
||||
|
||||
* **File**: colocate `Component.tsx`, `types.ts`, `stories.test.tsx` when relevant.
|
||||
* **Props**: strictly typed, minimal; thoughtful defaults.
|
||||
* **A11y**: labels, `aria-*`, keyboard nav; focus visible.
|
||||
* **States**: render empty/loader/error/success UI.
|
||||
* **Examples**: export a minimal demo in docs comment.
|
||||
* **Motion**: hover/press feedback; reduced‑motion support.
|
||||
|
||||
**Skeleton**
|
||||
|
||||
```tsx
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = { title: string; subtitle?: string; onClick?: () => void; className?: string };
|
||||
export function FeatureCard({ title, subtitle, onClick, className }: Props) {
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ y: -2 }}
|
||||
className={cn("card p-4", className)}
|
||||
role="button" tabIndex={0} onClick={onClick}
|
||||
>
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
{subtitle && <p className="mt-1 text-sm text-slate-500">{subtitle}</p>}
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9) Forms & Validation Standard
|
||||
|
||||
* **React Hook Form** + **Zod** schema; inline error messages; `aria-describedby`.
|
||||
* Mask sensitive inputs (OTP/phone). Provide helper text and constraints in UI.
|
||||
|
||||
**Snippet**
|
||||
|
||||
```tsx
|
||||
const schema = z.object({ email: z.string().email(), password: z.string().min(8) });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10) Data Layer Standard
|
||||
|
||||
* Use **TanStack Query** for async: caching, retries, invalidation.
|
||||
* Prefer optimistic updates; rollback on error.
|
||||
* Keep list/detail caches in sync using keys with filters.
|
||||
|
||||
---
|
||||
|
||||
## 11) Performance Budget
|
||||
|
||||
* Lighthouse targets: **Perf ≥ 90**, **A11y ≥ 95**.
|
||||
* Code splitting for routes and big components.
|
||||
* Preload critical fonts; responsive images with width/height to avoid CLS.
|
||||
|
||||
---
|
||||
|
||||
## 12) Testing Policy
|
||||
|
||||
* Unit tests for logic/conditional rendering.
|
||||
* Integration tests for forms + data fetch flows.
|
||||
* E2E tests for critical journeys (auth, checkout/donation, settings).
|
||||
|
||||
---
|
||||
|
||||
## 13) Security & Privacy
|
||||
|
||||
* Sanitize input; escape HTML.
|
||||
* Configure env via Vite; never commit secrets.
|
||||
* Use HTTP‑only cookies for auth tokens when applicable.
|
||||
* Respect CSP and same‑site cookies where relevant.
|
||||
|
||||
---
|
||||
|
||||
## 14) Communication & Explanation Style
|
||||
|
||||
* Be concise and practical. Explain *why* for key choices.
|
||||
* Provide **copy‑paste ready** blocks and **setup steps** when needed.
|
||||
* Offer alternatives if constraints exist, with trade‑offs.
|
||||
|
||||
---
|
||||
|
||||
## 15) Response Template (use for every answer)
|
||||
|
||||
1. **What you’ll build** (one sentence)
|
||||
2. **Files & changes** (paths + code blocks)
|
||||
3. **Usage example** (JSX snippet)
|
||||
4. **Notes** (a11y, perf, UX choices)
|
||||
5. **Next steps** (tests, variants, integrations)
|
||||
|
||||
---
|
||||
|
||||
## 16) Do / Don’t Quicklist
|
||||
|
||||
**Do**
|
||||
|
||||
* Ship complete, minimal, elegant solutions.
|
||||
* Cover states and a11y.
|
||||
* Keep visual polish subtle and modern.
|
||||
* Add brief inline comments for maintainers.
|
||||
|
||||
**Don’t**
|
||||
|
||||
* Over‑engineer or add heavy libs without reason.
|
||||
* Leave missing imports, undefined vars, or failing builds.
|
||||
* Ignore dark mode or responsive behavior.
|
||||
|
||||
---
|
||||
|
||||
## 17) i18n
|
||||
|
||||
* Externalize strings; support ID/EN switching.
|
||||
* Use `Intl` for number/date formatting.
|
||||
|
||||
---
|
||||
|
||||
## 18) Helper: `cn` utility
|
||||
|
||||
```ts
|
||||
export function cn(...classes: (string | undefined | null | false)[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 19) Example Ask (Prompt Format for Users)
|
||||
|
||||
> Build a bilingual (ID/EN) login + OTP flow using React + Vite + Tailwind, with glassmorphism card, orange accent, particles background with depth‑of‑field, Framer Motion transitions, RHF + Zod validation, loading/error states, and A11y best practices. Include tests and dark mode.
|
||||
|
||||
---
|
||||
|
||||
**End of System Instruction**
|
||||
|
||||
24
kediritechnopark-app/.gitignore
vendored
Normal file
24
kediritechnopark-app/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
kediritechnopark-app/README.md
Normal file
12
kediritechnopark-app/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
29
kediritechnopark-app/eslint.config.js
Normal file
29
kediritechnopark-app/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
kediritechnopark-app/index.html
Normal file
13
kediritechnopark-app/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4186
kediritechnopark-app/package-lock.json
generated
Normal file
4186
kediritechnopark-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
kediritechnopark-app/package.json
Normal file
35
kediritechnopark-app/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "kediritechnopark-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"framer-motion": "^11.3.24",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"zod": "^3.23.8",
|
||||
"@hookform/resolvers": "^3.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"vite": "^7.1.0",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"postcss": "^8.4.41",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
7
kediritechnopark-app/postcss.config.js
Normal file
7
kediritechnopark-app/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
1
kediritechnopark-app/public/vite.svg
Normal file
1
kediritechnopark-app/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
kediritechnopark-app/src/assets/kediri-technopark-logo-white.webp
Executable file
BIN
kediritechnopark-app/src/assets/kediri-technopark-logo-white.webp
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
kediritechnopark-app/src/assets/kediri-technopark-logo.webp
Executable file
BIN
kediritechnopark-app/src/assets/kediri-technopark-logo.webp
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
103
kediritechnopark-app/src/components/Auth/ForgotForm.tsx
Normal file
103
kediritechnopark-app/src/components/Auth/ForgotForm.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Field from "../UI/Field";
|
||||
import AnimatedAlerts from "../UI/AnimatedAlerts";
|
||||
|
||||
type Props = {
|
||||
t: any;
|
||||
palette: any;
|
||||
loading: boolean;
|
||||
globalOk?: string;
|
||||
globalErr?: string;
|
||||
shakeKey?: number;
|
||||
onForgot: (data: { email?: string; whatsapp?: string }) => void;
|
||||
go: (v: "login") => void;
|
||||
};
|
||||
|
||||
type Mode = "email" | "whatsapp";
|
||||
|
||||
export default function ForgotForm({ t, palette, loading, globalOk, globalErr, shakeKey, onForgot, go }: Props) {
|
||||
const [mode, setMode] = useState<Mode>("email");
|
||||
|
||||
const schema = useMemo(() =>
|
||||
mode === "email"
|
||||
? z.object({ email: z.string().email(t.errEmail) })
|
||||
: z.object({
|
||||
whatsapp: z
|
||||
.string()
|
||||
.regex(/^\d+$/, t.errWADigits)
|
||||
.transform((v) => (v.startsWith("0") ? `62${v.slice(1)}` : v.startsWith("8") ? `62${v}` : v))
|
||||
.refine((v) => v.startsWith("62"), t.errWAFormat),
|
||||
}),
|
||||
[mode, t]
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<any>({ resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = (data: any) => onForgot(data);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>
|
||||
|
||||
<div className="flex gap-2 p-1 rounded-xl border" style={{ background: "rgba(255,255,255,0.7)", borderColor: palette.border }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium ${mode === "email" ? "text-white shadow" : "text-gray-700 hover:bg-gray-50"}`}
|
||||
style={{ background: mode === "email" ? palette.primary : "transparent" }}
|
||||
onClick={() => setMode("email")}
|
||||
>
|
||||
{t.viaEmail}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium ${mode === "whatsapp" ? "text-white shadow" : "text-gray-700 hover:bg-gray-50"}`}
|
||||
style={{ background: mode === "whatsapp" ? palette.primary : "transparent" }}
|
||||
onClick={() => setMode("whatsapp")}
|
||||
>
|
||||
{t.viaWA}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === "email" ? (
|
||||
<Field id="email" label={t.email} error={errors.email?.message as string}>
|
||||
<input className="input" type="email" inputMode="email" placeholder="nama@domain.com" {...register("email")} />
|
||||
</Field>
|
||||
) : (
|
||||
<Field id="whatsapp" label={t.whatsapp} hint={t.waHint} error={errors.whatsapp?.message as string}>
|
||||
<div
|
||||
className="group flex items-stretch rounded-xl border bg-white/60 dark:bg-white/5 focus-within:ring-2 focus-within:ring-[rgb(var(--accent))]"
|
||||
style={{ borderColor: palette.border }}
|
||||
>
|
||||
<span className="inline-flex items-center px-3 rounded-l-xl text-gray-700 dark:text-gray-200 select-none">+62</span>
|
||||
<input
|
||||
className="flex-1 bg-transparent px-3 py-2 outline-none border-0 rounded-r-xl"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
placeholder="8xxxxxxxxx"
|
||||
{...register("whatsapp")}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<AnimatedAlerts ok={globalOk} err={globalErr} shakeKey={shakeKey} />
|
||||
|
||||
<button className="btn-primary w-full" style={{ background: palette.primary }} disabled={loading}>
|
||||
{loading ? (t.lang === "ID" ? "Memproses..." : "Processing...") : t.resetBtn}
|
||||
</button>
|
||||
|
||||
<p className="text-sm text-center">
|
||||
{t.backToLogin} {" "}
|
||||
<button type="button" className="link" style={{ color: palette.primary }} onClick={() => go("login")}>
|
||||
{t.toLogin}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
151
kediritechnopark-app/src/components/Auth/LoginForm.tsx
Normal file
151
kediritechnopark-app/src/components/Auth/LoginForm.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Field from "../UI/Field";
|
||||
import AnimatedAlerts from "../UI/AnimatedAlerts";
|
||||
|
||||
type Props = {
|
||||
t: any;
|
||||
palette: any;
|
||||
loading: boolean;
|
||||
globalOk?: string;
|
||||
globalErr?: string;
|
||||
shakeKey?: number;
|
||||
onLoginEmail: (data: { email: string; password: string }) => void;
|
||||
onLoginWARequestOTP: (data: { whatsapp: string }) => void;
|
||||
go: (v: "signup" | "forgot") => void;
|
||||
};
|
||||
|
||||
type Mode = "email" | "whatsapp";
|
||||
|
||||
export default function LoginForm({
|
||||
t,
|
||||
palette,
|
||||
loading,
|
||||
globalOk,
|
||||
globalErr,
|
||||
shakeKey,
|
||||
onLoginEmail,
|
||||
onLoginWARequestOTP,
|
||||
go,
|
||||
}: Props) {
|
||||
const [mode, setMode] = React.useState<Mode>("email");
|
||||
|
||||
const schema = useMemo(() =>
|
||||
mode === "email"
|
||||
? z.object({
|
||||
email: z.string().email(t.errEmail),
|
||||
password: z.string().min(1, t.errPassReq),
|
||||
})
|
||||
: z.object({
|
||||
whatsapp: z
|
||||
.string()
|
||||
.regex(/^\d+$/, t.errWADigits)
|
||||
.transform((v) => (v.startsWith("0") ? `62${v.slice(1)}` : v.startsWith("8") ? `62${v}` : v))
|
||||
.refine((v) => v.startsWith("62"), t.errWAFormat),
|
||||
}),
|
||||
[mode, t]
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<any>({ resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
if (mode === "email") onLoginEmail(data as { email: string; password: string });
|
||||
else onLoginWARequestOTP(data as { whatsapp: string });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>
|
||||
<div
|
||||
className="flex gap-2 p-1 rounded-xl border"
|
||||
style={{ background: "rgba(255,255,255,0.7)", borderColor: palette.border }}
|
||||
role="tablist"
|
||||
aria-label="Login method"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "email"}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium ${mode === "email" ? "text-white shadow" : "text-gray-700 hover:bg-gray-50"}`}
|
||||
style={{ background: mode === "email" ? palette.primary : "transparent" }}
|
||||
onClick={() => setMode("email")}
|
||||
>
|
||||
{t.email}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "whatsapp"}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium ${mode === "whatsapp" ? "text-white shadow" : "text-gray-700 hover:bg-gray-50"}`}
|
||||
style={{ background: mode === "whatsapp" ? palette.primary : "transparent" }}
|
||||
onClick={() => setMode("whatsapp")}
|
||||
>
|
||||
{t.whatsapp}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === "email" ? (
|
||||
<>
|
||||
<Field id="email" label={t.email} error={errors.email?.message as string}>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
placeholder="nama@domain.com"
|
||||
{...register("email")}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field id="password" label={t.password} error={errors.password?.message as string}>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...register("password")}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field id="whatsapp" label={t.whatsapp} hint={t.waHint} error={errors.whatsapp?.message as string}>
|
||||
<div
|
||||
className="group flex items-stretch rounded-xl border bg-white/60 dark:bg-white/5 focus-within:ring-2 focus-within:ring-[rgb(var(--accent))]"
|
||||
style={{ borderColor: palette.border }}
|
||||
>
|
||||
<span className="inline-flex items-center px-3 rounded-l-xl text-gray-700 dark:text-gray-200 select-none">+62</span>
|
||||
<input
|
||||
className="flex-1 bg-transparent px-3 py-2 outline-none border-0 rounded-r-xl"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
placeholder="8xxxxxxxxx"
|
||||
{...register("whatsapp")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<AnimatedAlerts ok={globalOk} err={globalErr} shakeKey={shakeKey} />
|
||||
|
||||
<button className="btn-primary w-full" style={{ background: palette.primary }} disabled={loading}>
|
||||
{loading ? (t.lang === "ID" ? "Memproses..." : "Processing...") : mode === "email" ? t.loginBtn : t.sendOTPWA}
|
||||
</button>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<button type="button" className="link" style={{ color: palette.primary }} onClick={() => go("forgot")}>
|
||||
{t.forgot}?
|
||||
</button>
|
||||
<button type="button" className="link" style={{ color: palette.primary }} onClick={() => go("signup")}>
|
||||
{t.toSignup}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
41
kediritechnopark-app/src/components/Auth/PasswordMeter.tsx
Normal file
41
kediritechnopark-app/src/components/Auth/PasswordMeter.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
|
||||
function checkPasswordStrength(pw: string) {
|
||||
const rules = {
|
||||
length: pw.length >= 8,
|
||||
upper: /[A-Z]/.test(pw),
|
||||
lower: /[a-z]/.test(pw),
|
||||
number: /\d/.test(pw),
|
||||
symbol: /[^A-Za-z0-9]/.test(pw),
|
||||
} as const;
|
||||
const passed = Object.values(rules).filter(Boolean).length;
|
||||
const score = passed / 5;
|
||||
return { rules, score, passed };
|
||||
}
|
||||
|
||||
export default function PasswordMeter({ value }: { value: string }) {
|
||||
const { rules, score } = checkPasswordStrength(value || "");
|
||||
const bars = 5;
|
||||
const filled = Math.round(score * bars);
|
||||
const Item = ({ ok, text }: { ok: boolean; text: string }) => (
|
||||
<li className={`text-[12px] ${ok ? "text-green-600" : "text-gray-500"}`}>• {text}</li>
|
||||
);
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="h-1.5 w-full bg-gray-200 rounded">
|
||||
<div
|
||||
className={`h-full rounded ${filled <= 2 ? "bg-red-500" : filled <= 4 ? "bg-amber-500" : "bg-green-500"}`}
|
||||
style={{ width: `${(filled / bars) * 100}%`, transition: "width .2s ease" }}
|
||||
/>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<Item ok={rules.length} text="≥ 8 karakter / 8+ chars" />
|
||||
<Item ok={rules.upper} text="Huruf besar / Uppercase" />
|
||||
<Item ok={rules.lower} text="Huruf kecil / Lowercase" />
|
||||
<Item ok={rules.number} text="Angka / Number" />
|
||||
<Item ok={rules.symbol} text="Simbol / Symbol" />
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
172
kediritechnopark-app/src/components/Auth/SignupForm.tsx
Normal file
172
kediritechnopark-app/src/components/Auth/SignupForm.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Field from "../UI/Field";
|
||||
import AnimatedAlerts from "../UI/AnimatedAlerts";
|
||||
// Password strength bar removed; replace with concise rule checklist
|
||||
|
||||
type Props = {
|
||||
t: any;
|
||||
palette: any;
|
||||
loading: boolean;
|
||||
globalOk?: string;
|
||||
globalErr?: string;
|
||||
shakeKey?: number;
|
||||
onSignup: (data: { name: string; email: string; whatsapp: string; password: string; confirm: string }) => void;
|
||||
go: (v: "login") => void;
|
||||
lang?: "ID" | "EN";
|
||||
};
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Required"),
|
||||
email: z.string().email("Invalid email"),
|
||||
whatsapp: z
|
||||
.string()
|
||||
.regex(/^\d+$/, "Digits only")
|
||||
.transform((v) => (v.startsWith("0") ? `62${v.slice(1)}` : v.startsWith("8") ? `62${v}` : v))
|
||||
.refine((v) => v.startsWith("62"), "Must start with 62xxxx."),
|
||||
password: z.string().min(8, "At least 8 chars"),
|
||||
confirm: z.string().min(1, "Required"),
|
||||
})
|
||||
.refine((d) => d.password === d.confirm, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirm"],
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
export default function SignupForm({ t, palette, loading, globalOk, globalErr, shakeKey, onSignup, go, lang }: Props) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({ resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = (data: FormData) => onSignup(data);
|
||||
|
||||
const pw = watch("password") || "";
|
||||
|
||||
function getRules(pwVal: string) {
|
||||
return {
|
||||
length: pwVal.length >= 8,
|
||||
upper: /[A-Z]/.test(pwVal),
|
||||
lower: /[a-z]/.test(pwVal),
|
||||
number: /\d/.test(pwVal),
|
||||
symbol: /[^A-Za-z0-9]/.test(pwVal),
|
||||
};
|
||||
}
|
||||
const rules = getRules(pw);
|
||||
|
||||
const isID = lang === "ID";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5" noValidate>
|
||||
<Field id="name" label={t.name} error={errors.name?.message}>
|
||||
<input className="input" placeholder={t.lang === "ID" ? "Nama kamu" : "Your name"} {...register("name")} required />
|
||||
</Field>
|
||||
<Field id="email" label={t.email} error={errors.email?.message}>
|
||||
<input className="input" type="email" inputMode="email" placeholder="nama@domain.com" {...register("email")} required />
|
||||
</Field>
|
||||
<Field id="whatsapp" label={t.whatsapp} hint={t.waHint} error={errors.whatsapp?.message}>
|
||||
<div
|
||||
className="group flex items-stretch rounded-xl border bg-white/60 dark:bg-white/5 focus-within:ring-2 focus-within:ring-[rgb(var(--accent))]"
|
||||
style={{ borderColor: palette.border }}
|
||||
>
|
||||
<span className="inline-flex items-center px-3 rounded-l-xl text-gray-700 dark:text-gray-200 select-none">+62</span>
|
||||
<input
|
||||
className="flex-1 bg-transparent px-3 py-2 outline-none border-0 rounded-r-xl"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
placeholder="8xxxxxxxxx"
|
||||
{...register("whatsapp")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field id="password" label={t.password} error={errors.password?.message}>
|
||||
<input className="input" type="password" placeholder={isID ? "Minimal 8 karakter" : "At least 8 characters"} {...register("password")} required />
|
||||
<ul className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<li className="flex items-center gap-1.5">
|
||||
{rules.length ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className="text-green-600" aria-hidden>
|
||||
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className="text-red-500" aria-hidden>
|
||||
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{isID ? "≥ 8 karakter" : "≥ 8 characters"}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
{rules.upper ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className="text-green-600" aria-hidden>
|
||||
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className="text-red-500" aria-hidden>
|
||||
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{isID ? "Huruf besar" : "Uppercase letter"}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
{rules.lower ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className="text-green-600" aria-hidden>
|
||||
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className="text-red-500" aria-hidden>
|
||||
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{isID ? "Huruf kecil" : "Lowercase letter"}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
{rules.number ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className="text-green-600" aria-hidden>
|
||||
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className="text-red-500" aria-hidden>
|
||||
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{isID ? "Angka" : "Number"}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
{rules.symbol ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className="text-green-600" aria-hidden>
|
||||
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" className="text-red-500" aria-hidden>
|
||||
<path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{isID ? "Simbol" : "Symbol"}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Field>
|
||||
<Field id="confirm" label={t.confirmPass} error={errors.confirm?.message}>
|
||||
<input className="input" type="password" placeholder={t.lang === "ID" ? "Ulangi password" : "Repeat password"} {...register("confirm")} required />
|
||||
</Field>
|
||||
|
||||
<AnimatedAlerts ok={globalOk} err={globalErr} shakeKey={shakeKey} />
|
||||
|
||||
<button className="btn-primary w-full" style={{ background: palette.primary }} disabled={loading}>
|
||||
{loading ? (t.lang === "ID" ? "Memproses..." : "Processing...") : t.signupBtn}
|
||||
</button>
|
||||
|
||||
<p className="text-sm text-center">
|
||||
{t.haveAcc} {" "}
|
||||
<button type="button" className="link" style={{ color: palette.primary }} onClick={() => go("login")}>
|
||||
{t.toLogin}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
68
kediritechnopark-app/src/components/Auth/VerifyForm.tsx
Normal file
68
kediritechnopark-app/src/components/Auth/VerifyForm.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useRef } from "react";
|
||||
import AnimatedAlerts from "../UI/AnimatedAlerts";
|
||||
import { onlyDigits } from "../../lib/utils";
|
||||
|
||||
type Props = {
|
||||
t: any;
|
||||
palette: any;
|
||||
loading: boolean;
|
||||
globalOk?: string;
|
||||
globalErr?: string;
|
||||
shakeKey?: number;
|
||||
otp: string[];
|
||||
setOtp: (val: string[]) => void;
|
||||
handleVerify: () => void;
|
||||
go: (v: "login") => void;
|
||||
};
|
||||
|
||||
export default function VerifyForm({ t, palette, loading, globalOk, globalErr, shakeKey, otp, setOtp, handleVerify, go }: Props) {
|
||||
const otpRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
handleVerify();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-6" noValidate>
|
||||
<p className="text-sm text-gray-600">{t.otpDesc}</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
{otp.map((v, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => (otpRefs.current[i] = el)}
|
||||
className="w-11 h-12 text-center border rounded-lg outline-none text-lg"
|
||||
style={{ borderColor: palette.border }}
|
||||
value={v}
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
onChange={(e) => {
|
||||
const d = onlyDigits(e.target.value).slice(0, 1);
|
||||
const next = [...otp];
|
||||
next[i] = d;
|
||||
setOtp(next);
|
||||
if (d && i < 5) otpRefs.current[i + 1]?.focus();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Backspace" && !otp[i] && i > 0) otpRefs.current[i - 1]?.focus();
|
||||
}}
|
||||
aria-label={`OTP digit ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatedAlerts ok={globalOk} err={globalErr} shakeKey={shakeKey} />
|
||||
|
||||
<button className="btn-primary w-full" style={{ background: palette.primary }} disabled={loading}>
|
||||
{loading ? (t.lang === "ID" ? "Memproses..." : "Processing...") : t.verifyBtn}
|
||||
</button>
|
||||
|
||||
<p className="text-sm text-center">
|
||||
{t.notReceive} {" "}
|
||||
<button type="button" className="link" style={{ color: palette.primary }} onClick={() => go("login")}>
|
||||
{t.resend}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
213
kediritechnopark-app/src/components/Background/TechParticles.tsx
Normal file
213
kediritechnopark-app/src/components/Background/TechParticles.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
type Palette = {
|
||||
bgFrom: string;
|
||||
bgTo: string;
|
||||
text: string;
|
||||
glass: string;
|
||||
border: string;
|
||||
primary: string;
|
||||
primaryHover: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
palette: Palette;
|
||||
className?: string;
|
||||
// Approximate particles per (25k px^2). 2 is a good default.
|
||||
density?: number;
|
||||
// Speed multiplier to make motion faster/slower (1 = default).
|
||||
speedMultiplier?: number;
|
||||
// Depth-of-field intensity in pixels (higher = more blur off-focus).
|
||||
dofIntensity?: number;
|
||||
};
|
||||
|
||||
type Particle = {
|
||||
ax: number; // anchor x (px)
|
||||
ay: number; // anchor y (px)
|
||||
x: number; // current x (px)
|
||||
y: number; // current y (px)
|
||||
phase: number; // oscillator phase
|
||||
speed: number; // phase speed
|
||||
ampX: number; // local oscillation amplitude x
|
||||
ampY: number; // local oscillation amplitude y
|
||||
radius: number; // base dot radius (px)
|
||||
z: number; // 0..1 depth (0 near, 1 far)
|
||||
hue: string; // color hex
|
||||
};
|
||||
|
||||
function usePrefersReducedMotion() {
|
||||
const ref = useRef(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
ref.current = mq.matches;
|
||||
const handler = () => (ref.current = mq.matches);
|
||||
mq.addEventListener?.("change", handler);
|
||||
return () => mq.removeEventListener?.("change", handler);
|
||||
}, []);
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function TechParticles({ palette, className, density = 2, speedMultiplier = 1.2, dofIntensity = 6 }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const motionPref = usePrefersReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current!;
|
||||
const ctx = canvas.getContext("2d", { alpha: true })!;
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
||||
let raf = 0;
|
||||
let running = true;
|
||||
let last = performance.now();
|
||||
// no parallax/pointer effect per request
|
||||
|
||||
const colors = [palette.primary, palette.primaryHover];
|
||||
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
const pick = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
|
||||
let particles: Particle[] = [];
|
||||
|
||||
function regenerateParticles() {
|
||||
const areaK = (width * height) / 25000; // per 25k px
|
||||
const count = Math.max(18, Math.min(120, Math.floor(areaK * density)));
|
||||
particles = new Array(count).fill(0).map(() => {
|
||||
const ax = Math.random() * width;
|
||||
const ay = Math.random() * height;
|
||||
const radius = rand(1, 2.2);
|
||||
return {
|
||||
ax,
|
||||
ay,
|
||||
x: ax,
|
||||
y: ay,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
// faster by default, still gentle
|
||||
speed: rand(0.8, 1.8),
|
||||
ampX: rand(2, 8),
|
||||
ampY: rand(2, 8),
|
||||
radius,
|
||||
z: Math.random(),
|
||||
hue: pick(colors),
|
||||
} as Particle;
|
||||
});
|
||||
}
|
||||
|
||||
function resize() {
|
||||
width = canvas.clientWidth;
|
||||
height = canvas.clientHeight;
|
||||
dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
regenerateParticles();
|
||||
}
|
||||
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(canvas);
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
function step(now: number) {
|
||||
if (!running) return;
|
||||
const dt = Math.min(1 / 15, (now - last) / 1000);
|
||||
last = now;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// soft gradient base
|
||||
const grd = ctx.createLinearGradient(0, 0, 0, height);
|
||||
grd.addColorStop(0, palette.bgFrom);
|
||||
grd.addColorStop(1, palette.bgTo);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const reduced = motionPref.current;
|
||||
|
||||
// update positions; each dot oscillates around its anchor (no center drift)
|
||||
for (let p of particles) {
|
||||
if (!reduced) p.phase += p.speed * dt * speedMultiplier;
|
||||
p.x = p.ax + Math.cos(p.phase) * p.ampX;
|
||||
p.y = p.ay + Math.sin(p.phase * 0.85) * p.ampY;
|
||||
}
|
||||
|
||||
// connections (constellation): nearby lines
|
||||
const threshold = Math.min(120, Math.max(60, Math.min(width, height) * 0.14));
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const a = particles[i];
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const b = particles[j];
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist > threshold) continue;
|
||||
const near = 1 - dist / threshold; // 0..1
|
||||
// depth-of-field for lines: blur more if away from focal plane
|
||||
const meanZ = (a.z + b.z) / 2;
|
||||
const lineBlur = Math.abs(meanZ - 0.5) * dofIntensity;
|
||||
ctx.save();
|
||||
// lines fade with distance and depth
|
||||
ctx.globalAlpha = Math.max(0.06, 0.28 * near * (1 - meanZ * 0.5));
|
||||
(ctx as any).filter = `blur(${lineBlur}px)`;
|
||||
ctx.strokeStyle = palette.primaryHover;
|
||||
ctx.lineWidth = 0.6 + near * 0.7;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// dots
|
||||
for (const p of particles) {
|
||||
// depth-of-field for dots
|
||||
const blur = Math.abs(p.z - 0.5) * dofIntensity;
|
||||
const size = p.radius * (1.1 + (1 - p.z) * 1.4); // near = larger
|
||||
const alpha = 0.5 + (1 - p.z) * 0.35; // near = more opaque
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
(ctx as any).filter = `blur(${blur}px)`;
|
||||
ctx.fillStyle = p.hue;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function onVis() {
|
||||
running = document.visibilityState === "visible";
|
||||
if (running) {
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(step);
|
||||
} else {
|
||||
cancelAnimationFrame(raf);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
|
||||
raf = requestAnimationFrame(step);
|
||||
|
||||
return () => {
|
||||
running = false;
|
||||
cancelAnimationFrame(raf);
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
window.removeEventListener("resize", resize);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [palette, density]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden
|
||||
className={["absolute inset-0 w-full h-full", className || ""].join(" ")}
|
||||
style={{ zIndex: -5 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TechParticles;
|
||||
35
kediritechnopark-app/src/components/UI/AnimatedAlerts.tsx
Normal file
35
kediritechnopark-app/src/components/UI/AnimatedAlerts.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
ok?: string;
|
||||
err?: string;
|
||||
shakeKey?: number;
|
||||
};
|
||||
|
||||
export default function AnimatedAlerts({ ok, err, shakeKey }: Props) {
|
||||
return (
|
||||
<>
|
||||
{err && (
|
||||
<div
|
||||
role="alert"
|
||||
key={`e-${shakeKey}`}
|
||||
className="alert-anim text-sm rounded-lg px-3 py-2 border"
|
||||
style={{ background: "#fff", borderColor: "rgba(239,68,68,0.25)", color: "#B91C1C" }}
|
||||
>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
{ok && (
|
||||
<div
|
||||
role="status"
|
||||
key={`o-${shakeKey}`}
|
||||
className="alert-anim text-sm rounded-lg px-3 py-2 border"
|
||||
style={{ background: "#fff", borderColor: "rgba(34,197,94,0.25)", color: "#166534" }}
|
||||
>
|
||||
{ok}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
38
kediritechnopark-app/src/components/UI/Field.tsx
Normal file
38
kediritechnopark-app/src/components/UI/Field.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
id?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function Field({ label, hint, error, id, children }: Props) {
|
||||
const describedBy = error ? `${id}-error` : hint ? `${id}-hint` : undefined;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{React.isValidElement(children)
|
||||
? React.cloneElement(children as React.ReactElement, {
|
||||
id,
|
||||
"aria-invalid": !!error || undefined,
|
||||
"aria-describedby": describedBy,
|
||||
})
|
||||
: children}
|
||||
{hint && !error && (
|
||||
<p id={`${id}-hint`} className="text-[12px] text-slate-500">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="text-[12px] text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
115
kediritechnopark-app/src/components/UI/TechLinkedParticlesBG.jsx
Normal file
115
kediritechnopark-app/src/components/UI/TechLinkedParticlesBG.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { hexToRgba } from "../../lib/utils";
|
||||
|
||||
export default function TechLinkedParticlesBG({ theme, speed = 2 }) {
|
||||
const canvasRef = useRef(null);
|
||||
const DPR = typeof window !== "undefined" ? Math.min(2, window.devicePixelRatio || 1) : 1;
|
||||
const mouse = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d");
|
||||
let W = (canvas.width = window.innerWidth * DPR);
|
||||
let H = (canvas.height = window.innerHeight * DPR);
|
||||
|
||||
// 3 layers: near (sharp), mid (blur), far (more blur)
|
||||
const layers = [
|
||||
{ count: 60, size: [1.8, 2.6], speed: 0.22, blur: 0, linkDist: 130, color: theme.primary, alpha: 0.28 },
|
||||
{ count: 70, size: [2.2, 3.6], speed: 0.12, blur: 4, linkDist: 170, color: theme.primary, alpha: 0.18 },
|
||||
{ count: 60, size: [2.8, 4.6], speed: 0.06, blur: 10, linkDist: 210, color: "#64748b", alpha: 0.12 },
|
||||
];
|
||||
const particles = layers.map((L, li) =>
|
||||
Array.from({ length: L.count }).map(() => ({
|
||||
x: Math.random() * W,
|
||||
y: Math.random() * H,
|
||||
r: (Math.random() * (L.size[1] - L.size[0]) + L.size[0]) * DPR,
|
||||
vx: (Math.random() - 0.5) * L.speed * speed * DPR,
|
||||
vy: (Math.random() - 0.5) * L.speed * speed * DPR,
|
||||
layer: li,
|
||||
tick: Math.random() * 1000,
|
||||
}))
|
||||
);
|
||||
|
||||
const onResize = () => {
|
||||
W = canvas.width = window.innerWidth * DPR;
|
||||
H = canvas.height = window.innerHeight * DPR;
|
||||
};
|
||||
const onMouseMove = (e) => {
|
||||
mouse.current.x = (e.clientX / window.innerWidth - 0.5) * 2;
|
||||
mouse.current.y = (e.clientY / window.innerHeight - 0.5) * 2;
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
let raf;
|
||||
const draw = () => {
|
||||
// background gradient
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
const g = ctx.createLinearGradient(0, 0, 0, H);
|
||||
g.addColorStop(0, theme.bgFrom);
|
||||
g.addColorStop(1, theme.bgTo);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
layers.forEach((L, li) => {
|
||||
// update + draw particles
|
||||
ctx.filter = L.blur ? `blur(${L.blur}px)` : "none";
|
||||
particles[li].forEach((p) => {
|
||||
p.x += p.vx; p.y += p.vy; p.tick += 0.002 + li * 0.001;
|
||||
// gentle breathing scale for DoF feel
|
||||
const breathe = 0.85 + Math.sin(p.tick * 2) * 0.15;
|
||||
// wrap
|
||||
if (p.x < -20) p.x = W + 20;
|
||||
if (p.x > W + 20) p.x = -20;
|
||||
if (p.y < -20) p.y = H + 20;
|
||||
if (p.y > H + 20) p.y = -20;
|
||||
|
||||
// parallax by layer
|
||||
const par = (li + 1) * 5;
|
||||
const ox = mouse.current.x * par * DPR;
|
||||
const oy = mouse.current.y * par * DPR;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = hexToRgba(L.color, L.alpha);
|
||||
ctx.arc(p.x + ox, p.y + oy, p.r * breathe, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// linked lines within layer (dynamic)
|
||||
// (do this after drawing points to keep blur affecting only nodes)
|
||||
ctx.filter = "none";
|
||||
for (let i = 0; i < particles[li].length; i++) {
|
||||
const a = particles[li][i];
|
||||
for (let j = i + 1; j < particles[li].length; j++) {
|
||||
const b = particles[li][j];
|
||||
const dx = a.x - b.x, dy = a.y - b.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < L.linkDist * DPR) {
|
||||
const par = (li + 1) * 5;
|
||||
const ox = mouse.current.x * par * DPR;
|
||||
const oy = mouse.current.y * par * DPR;
|
||||
const alpha = (1 - dist / (L.linkDist * DPR)) * L.alpha * 0.9;
|
||||
ctx.strokeStyle = hexToRgba(L.color, alpha);
|
||||
ctx.lineWidth = (li === 0 ? 1.2 : li === 1 ? 1 : 0.8) * DPR;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x + ox, a.y + oy);
|
||||
ctx.lineTo(b.x + ox, b.y + oy);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
};
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", onResize);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
};
|
||||
}, [DPR, theme, speed]);
|
||||
|
||||
return <canvas ref={canvasRef} className="fixed inset-0 -z-10" style={{ width: "100vw", height: "100vh" }} />;
|
||||
}
|
||||
2
kediritechnopark-app/src/config/constants.js
Normal file
2
kediritechnopark-app/src/config/constants.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// ganti ke webhook kamu
|
||||
export const N8N_WEBHOOK_URL = import.meta.env.VITE_N8N_WEBHOOK_URL || "https://your-n8n.example/webhook/auth";
|
||||
92
kediritechnopark-app/src/config/i18n.js
Normal file
92
kediritechnopark-app/src/config/i18n.js
Normal file
@@ -0,0 +1,92 @@
|
||||
export const T = {
|
||||
ID: {
|
||||
title: "Welcome Back 👋",
|
||||
subtitle: "Akses akunmu dengan aman.",
|
||||
login: "Masuk",
|
||||
signup: "Buat Akun",
|
||||
verify: "Verifikasi OTP",
|
||||
forgot: "Lupa Password",
|
||||
email: "Email",
|
||||
password: "Password",
|
||||
whatsapp: "WhatsApp",
|
||||
viaEmail: "Via Email",
|
||||
viaWA: "Via WhatsApp",
|
||||
sendOTPWA: "Kirim OTP via WhatsApp",
|
||||
loginBtn: "Login",
|
||||
signupBtn: "Daftar",
|
||||
verifyBtn: "Verifikasi",
|
||||
resetBtn: "Kirim Reset",
|
||||
notReceive: "Tidak menerima kode?",
|
||||
resend: "Kirim ulang",
|
||||
haveAcc: "Sudah punya akun?",
|
||||
toLogin: "Masuk",
|
||||
toSignup: "Buat akun",
|
||||
backToLogin: "Kembali ke",
|
||||
name: "Nama Lengkap",
|
||||
confirmPass: "Konfirmasi Password",
|
||||
otpDesc: "Masukkan kode 6 digit yang kami kirim.",
|
||||
waHint: "Gunakan format 62xxxxxxxxxx (tanpa 0 di depan)",
|
||||
enterEmailOrWA: "Isi salah satu: Email atau WhatsApp.",
|
||||
tosNote: "Dengan masuk, kamu menyetujui Ketentuan Layanan & Kebijakan Privasi.",
|
||||
// errors
|
||||
errEmail: "Format email tidak valid.",
|
||||
errPassReq: "Password wajib diisi.",
|
||||
errNameReq: "Nama wajib diisi.",
|
||||
errWAFormat: "Nomor WA harus format 62xxxxxxxxxx.",
|
||||
errWADigits: "Nomor WA hanya boleh angka.",
|
||||
errPassMismatch: "Konfirmasi password tidak cocok.",
|
||||
errPassWeak: "Password belum memenuhi semua kriteria.",
|
||||
errOTP: "Kode OTP harus 6 digit angka.",
|
||||
// success
|
||||
okLogin: "Login email berhasil.",
|
||||
okSendOTP: "OTP dikirim ke WhatsApp.",
|
||||
okSignup: "Signup berhasil. OTP dikirim untuk verifikasi.",
|
||||
okVerify: "Verifikasi berhasil.",
|
||||
okReset: "Tautan/kode reset dikirim.",
|
||||
},
|
||||
EN: {
|
||||
title: "Welcome Back 👋",
|
||||
subtitle: "Securely access your account.",
|
||||
login: "Login",
|
||||
signup: "Sign Up",
|
||||
verify: "Verify OTP",
|
||||
forgot: "Forgot Password",
|
||||
email: "Email",
|
||||
password: "Password",
|
||||
whatsapp: "WhatsApp",
|
||||
viaEmail: "Via Email",
|
||||
viaWA: "Via WhatsApp",
|
||||
sendOTPWA: "Send OTP via WhatsApp",
|
||||
loginBtn: "Login",
|
||||
signupBtn: "Create Account",
|
||||
verifyBtn: "Verify",
|
||||
resetBtn: "Send Reset",
|
||||
notReceive: "Didn't receive the code?",
|
||||
resend: "Resend",
|
||||
haveAcc: "Already have an account?",
|
||||
toLogin: "Login",
|
||||
toSignup: "Create one",
|
||||
backToLogin: "Back to",
|
||||
name: "Full Name",
|
||||
confirmPass: "Confirm Password",
|
||||
otpDesc: "Enter the 6‑digit code we sent you.",
|
||||
waHint: "Use 62xxxxxxxxxx format (no leading 0)",
|
||||
enterEmailOrWA: "Fill one: Email or WhatsApp.",
|
||||
tosNote: "By signing in, you agree to our Terms & Privacy.",
|
||||
// errors
|
||||
errEmail: "Invalid email format.",
|
||||
errPassReq: "Password is required.",
|
||||
errNameReq: "Name is required.",
|
||||
errWAFormat: "Phone must be in 62xxxxxxxxxx format.",
|
||||
errWADigits: "Phone number must be digits only.",
|
||||
errPassMismatch: "Passwords do not match.",
|
||||
errPassWeak: "Password does not meet all requirements.",
|
||||
errOTP: "OTP must be 6 digits.",
|
||||
// success
|
||||
okLogin: "Logged in with email.",
|
||||
okSendOTP: "OTP sent via WhatsApp.",
|
||||
okSignup: "Sign up successful. OTP sent for verification.",
|
||||
okVerify: "Verification successful.",
|
||||
okReset: "Reset link/code has been sent.",
|
||||
},
|
||||
};
|
||||
26
kediritechnopark-app/src/config/theme.js
Normal file
26
kediritechnopark-app/src/config/theme.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export const THEME = {
|
||||
light: {
|
||||
bgFrom: "#f6fbff",
|
||||
bgTo: "#eaf5ff",
|
||||
text: "#0b1220",
|
||||
glass: "rgba(255,255,255,0.9)",
|
||||
border: "#E5E7EB",
|
||||
primary: "#2563EB",
|
||||
primaryHover: "#1E4FD7",
|
||||
},
|
||||
dark: {
|
||||
bgFrom: "#0b1220",
|
||||
bgTo: "#0f172a",
|
||||
text: "#e6eefc",
|
||||
glass: "rgba(255,255,255,0.08)",
|
||||
border: "rgba(255,255,255,0.12)",
|
||||
primary: "#4F86FF",
|
||||
primaryHover: "#3B6FE6",
|
||||
},
|
||||
};
|
||||
|
||||
export const COLORS = {
|
||||
success: "#22C55E",
|
||||
danger: "#EF4444",
|
||||
amber: "#F59E0B",
|
||||
};
|
||||
54
kediritechnopark-app/src/index.css
Normal file
54
kediritechnopark-app/src/index.css
Normal file
@@ -0,0 +1,54 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Theme tokens */
|
||||
:root {
|
||||
--bg: 255 255 255;
|
||||
--surface: 248 250 252;
|
||||
--text: 17 24 39;
|
||||
--primary: 255 115 0;
|
||||
--accent: 14 165 233;
|
||||
--muted: 100 116 139;
|
||||
}
|
||||
.dark {
|
||||
--bg: 2 6 23;
|
||||
--surface: 15 23 42;
|
||||
--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 {
|
||||
.card { @apply rounded-2xl bg-[rgb(var(--surface))]/80 backdrop-blur-xl shadow-lg; }
|
||||
/* Consistent glassmorphism card for light/dark */
|
||||
/* More transparent + stronger blur for true glass effect */
|
||||
.glass-card { @apply rounded-2xl backdrop-blur-2xl backdrop-saturate-150 shadow-xl border bg-white/25 dark:bg-white/5 border-white/30 dark:border-white/10; }
|
||||
/* Solid card (no transparency), uses theme surface */
|
||||
.solid-card { @apply rounded-2xl bg-[rgb(var(--surface))] shadow-xl border border-slate-200/70 dark:border-white/10; }
|
||||
.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; }
|
||||
.input { @apply w-full rounded-xl border border-slate-200/60 bg-white/60 px-3 py-2 outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]; }
|
||||
}
|
||||
|
||||
/* micro animations used by alerts */
|
||||
@keyframes shakeX {
|
||||
0%,100%{transform:translateX(0)}
|
||||
20%{transform:translateX(-2px)}
|
||||
40%{transform:translateX(2px)}
|
||||
60%{transform:translateX(-2px)}
|
||||
80%{transform:translateX(2px)}
|
||||
}
|
||||
.alert-anim {
|
||||
animation: fadeIn .18s ease, shakeX .24s ease;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from{opacity:0; transform:translateY(-2px)}
|
||||
to{opacity:1; transform:translateY(0)}
|
||||
}
|
||||
22
kediritechnopark-app/src/lib/api.ts
Normal file
22
kediritechnopark-app/src/lib/api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { N8N_WEBHOOK_URL } from "../config/constants";
|
||||
|
||||
type Payload = Record<string, unknown> & {
|
||||
action: string;
|
||||
meta?: Record<string, unknown>;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export async function postToN8N(payload: Payload) {
|
||||
const res = await fetch(N8N_WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()) || "Request failed");
|
||||
try {
|
||||
return (await res.json()) as unknown;
|
||||
} catch {
|
||||
return {} as unknown;
|
||||
}
|
||||
}
|
||||
|
||||
4
kediritechnopark-app/src/lib/cn.ts
Normal file
4
kediritechnopark-app/src/lib/cn.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function cn(...classes: Array<string | undefined | null | false>) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
22
kediritechnopark-app/src/lib/utils.ts
Normal file
22
kediritechnopark-app/src/lib/utils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i;
|
||||
export const onlyDigits = (s: string = "") => (s || "").replace(/\D+/g, "");
|
||||
|
||||
export function normalizeIDPhone(input: string) {
|
||||
let d = onlyDigits(input);
|
||||
if (!d) return "";
|
||||
if (d.startsWith("0")) d = "62" + d.slice(1);
|
||||
else if (d.startsWith("8")) d = "62" + d;
|
||||
return d;
|
||||
}
|
||||
|
||||
export function hexToRgba(hex: string, alpha: number = 1) {
|
||||
if (hex.startsWith("rgb")) return hex;
|
||||
const h = hex.replace("#", "");
|
||||
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
||||
const bigint = parseInt(full, 16);
|
||||
const r = (bigint >> 16) & 255,
|
||||
g = (bigint >> 8) & 255,
|
||||
b = bigint & 255;
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
10
kediritechnopark-app/src/main.tsx
Normal file
10
kediritechnopark-app/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import AuthPage from './pages/AuthPage'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<AuthPage />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
403
kediritechnopark-app/src/pages/AuthPage.tsx
Normal file
403
kediritechnopark-app/src/pages/AuthPage.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { THEME } from "../config/theme";
|
||||
import { T } from "../config/i18n";
|
||||
import { normalizeIDPhone, emailRegex } from "../lib/utils";
|
||||
import { postToN8N } from "../lib/api";
|
||||
import LoginForm from "../components/Auth/LoginForm";
|
||||
import SignupForm from "../components/Auth/SignupForm";
|
||||
import VerifyForm from "../components/Auth/VerifyForm";
|
||||
import ForgotForm from "../components/Auth/ForgotForm";
|
||||
import TechParticles from "../components/Background/TechParticles";
|
||||
import ktpLogo from "../assets/kediri-technopark-logo.webp";
|
||||
import ktpLogoWhite from "../assets/kediri-technopark-logo-white.webp";
|
||||
|
||||
type Lang = "ID" | "EN";
|
||||
type View = "login" | "signup" | "verify" | "forgot";
|
||||
|
||||
export default function AuthPage() {
|
||||
const [lang, setLang] = useState<Lang>("ID");
|
||||
const [dark, setDark] = useState(false);
|
||||
const palette = dark ? THEME.dark : THEME.light;
|
||||
const t = T[lang];
|
||||
|
||||
useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
if (dark) el.classList.add("dark");
|
||||
else el.classList.remove("dark");
|
||||
}, [dark]);
|
||||
|
||||
const [view, setView] = useState<View>("login");
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [shellH, setShellH] = useState<string>("auto");
|
||||
useEffect(() => {
|
||||
const node = cardRef.current;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!node) return;
|
||||
const h = node.getBoundingClientRect().height;
|
||||
setShellH(h + "px");
|
||||
});
|
||||
if (node) ro.observe(node);
|
||||
return () => ro.disconnect();
|
||||
}, [view]);
|
||||
|
||||
const go = (next: View) => {
|
||||
cardRef.current?.setAttribute("data-transition", "changing");
|
||||
setTimeout(() => {
|
||||
setView(next);
|
||||
requestAnimationFrame(() => {
|
||||
cardRef.current?.setAttribute("data-transition", "idle");
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// OTP state
|
||||
const [otp, setOtp] = useState<string[]>(Array(6).fill(""));
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [globalErr, setGlobalErr] = useState("");
|
||||
const [globalOk, setGlobalOk] = useState("");
|
||||
const [shakeKey, setShakeKey] = useState(0);
|
||||
const triggerShake = () => setShakeKey((k) => k + 1);
|
||||
|
||||
// Handlers: now accept validated data from child forms
|
||||
async function handleLoginEmail(data: { email: string; password: string }) {
|
||||
setGlobalErr("");
|
||||
setGlobalOk("");
|
||||
if (!emailRegex.test(data.email)) {
|
||||
setGlobalErr(t.errEmail);
|
||||
return triggerShake();
|
||||
}
|
||||
if (!data.password) {
|
||||
setGlobalErr(t.errPassReq);
|
||||
return triggerShake();
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await postToN8N({
|
||||
action: "login_email",
|
||||
meta: { channel: "web", brand: "kloowear", lang, dark },
|
||||
data: { email: data.email, password: data.password },
|
||||
});
|
||||
setGlobalOk(t.okLogin);
|
||||
} catch (err) {
|
||||
setGlobalErr((err as Error).message || "Error");
|
||||
triggerShake();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function handleLoginWARequestOTP(data: { whatsapp: string }) {
|
||||
setGlobalErr("");
|
||||
setGlobalOk("");
|
||||
const wa = normalizeIDPhone(data.whatsapp);
|
||||
if (!wa.startsWith("62")) {
|
||||
setGlobalErr(t.errWAFormat);
|
||||
return triggerShake();
|
||||
}
|
||||
if (!/^\d+$/.test(wa)) {
|
||||
setGlobalErr(t.errWADigits);
|
||||
return triggerShake();
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await postToN8N({
|
||||
action: "login_whatsapp_request_otp",
|
||||
meta: { channel: "web", brand: "kloowear", lang, dark },
|
||||
data: { whatsapp: wa },
|
||||
});
|
||||
setGlobalOk(t.okSendOTP);
|
||||
go("verify");
|
||||
} catch (err) {
|
||||
setGlobalErr((err as Error).message || "Error");
|
||||
triggerShake();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
function checkPasswordStrengthAll(pw: string) {
|
||||
const rules = {
|
||||
length: pw.length >= 8,
|
||||
upper: /[A-Z]/.test(pw),
|
||||
lower: /[a-z]/.test(pw),
|
||||
number: /\d/.test(pw),
|
||||
symbol: /[^A-Za-z0-9]/.test(pw),
|
||||
};
|
||||
return Object.values(rules).every(Boolean);
|
||||
}
|
||||
async function handleSignup(data: { name: string; email: string; whatsapp: string; password: string; confirm: string }) {
|
||||
setGlobalErr("");
|
||||
setGlobalOk("");
|
||||
if (!data.name.trim()) {
|
||||
setGlobalErr(t.errNameReq);
|
||||
return triggerShake();
|
||||
}
|
||||
if (!emailRegex.test(data.email)) {
|
||||
setGlobalErr(t.errEmail);
|
||||
return triggerShake();
|
||||
}
|
||||
const wa = normalizeIDPhone(data.whatsapp);
|
||||
if (!wa.startsWith("62")) {
|
||||
setGlobalErr(t.errWAFormat);
|
||||
return triggerShake();
|
||||
}
|
||||
if (!/^\d+$/.test(wa)) {
|
||||
setGlobalErr(t.errWADigits);
|
||||
return triggerShake();
|
||||
}
|
||||
if (data.password !== data.confirm) {
|
||||
setGlobalErr(t.errPassMismatch);
|
||||
return triggerShake();
|
||||
}
|
||||
if (!checkPasswordStrengthAll(data.password)) {
|
||||
setGlobalErr(t.errPassWeak);
|
||||
return triggerShake();
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await postToN8N({
|
||||
action: "signup",
|
||||
meta: { channel: "web", brand: "kloowear", lang, dark },
|
||||
data: {
|
||||
name: data.name.trim(),
|
||||
email: data.email.trim(),
|
||||
whatsapp: wa,
|
||||
password: data.password,
|
||||
},
|
||||
});
|
||||
setGlobalOk(t.okSignup);
|
||||
go("verify");
|
||||
} catch (err) {
|
||||
setGlobalErr((err as Error).message || "Error");
|
||||
triggerShake();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function handleVerify() {
|
||||
setGlobalErr("");
|
||||
setGlobalOk("");
|
||||
const code = otp.join("");
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
setGlobalErr(t.errOTP);
|
||||
return triggerShake();
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await postToN8N({
|
||||
action: "verify_otp",
|
||||
meta: { channel: "web", brand: "kloowear", lang, dark },
|
||||
data: { otp: code },
|
||||
});
|
||||
setGlobalOk(t.okVerify);
|
||||
} catch (err) {
|
||||
setGlobalErr((err as Error).message || "Error");
|
||||
triggerShake();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function handleForgot(data: { email?: string; whatsapp?: string }) {
|
||||
setGlobalErr("");
|
||||
setGlobalOk("");
|
||||
if (data.email) {
|
||||
if (!data.email.trim() || !emailRegex.test(data.email)) {
|
||||
setGlobalErr(t.errEmail);
|
||||
return triggerShake();
|
||||
}
|
||||
}
|
||||
if (data.whatsapp) {
|
||||
const wa = normalizeIDPhone(data.whatsapp);
|
||||
if (!wa.startsWith("62")) {
|
||||
setGlobalErr(t.errWAFormat);
|
||||
return triggerShake();
|
||||
}
|
||||
if (!/^\d+$/.test(wa)) {
|
||||
setGlobalErr(t.errWADigits);
|
||||
return triggerShake();
|
||||
}
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await postToN8N({
|
||||
action: "forgot_password_request",
|
||||
meta: { channel: "web", brand: "kloowear", lang, dark },
|
||||
data: {
|
||||
email: data.email?.trim(),
|
||||
whatsapp: data.whatsapp ? normalizeIDPhone(data.whatsapp) : undefined,
|
||||
},
|
||||
});
|
||||
setGlobalOk(t.okReset);
|
||||
} catch (err) {
|
||||
setGlobalErr((err as Error).message || "Error");
|
||||
triggerShake();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const renderView = () => {
|
||||
switch (view) {
|
||||
case "signup":
|
||||
return (
|
||||
<SignupForm
|
||||
t={t}
|
||||
palette={palette}
|
||||
loading={loading}
|
||||
globalOk={globalOk}
|
||||
globalErr={globalErr}
|
||||
shakeKey={shakeKey}
|
||||
onSignup={handleSignup}
|
||||
go={go}
|
||||
lang={lang}
|
||||
/>
|
||||
);
|
||||
case "verify":
|
||||
return (
|
||||
<VerifyForm
|
||||
t={t}
|
||||
palette={palette}
|
||||
loading={loading}
|
||||
globalOk={globalOk}
|
||||
globalErr={globalErr}
|
||||
shakeKey={shakeKey}
|
||||
otp={otp}
|
||||
setOtp={setOtp}
|
||||
handleVerify={handleVerify}
|
||||
go={go}
|
||||
/>
|
||||
);
|
||||
case "forgot":
|
||||
return (
|
||||
<ForgotForm
|
||||
t={t}
|
||||
palette={palette}
|
||||
loading={loading}
|
||||
globalOk={globalOk}
|
||||
globalErr={globalErr}
|
||||
shakeKey={shakeKey}
|
||||
onForgot={handleForgot}
|
||||
go={go}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<LoginForm
|
||||
t={t}
|
||||
palette={palette}
|
||||
loading={loading}
|
||||
globalOk={globalOk}
|
||||
globalErr={globalErr}
|
||||
shakeKey={shakeKey}
|
||||
onLoginEmail={handleLoginEmail}
|
||||
onLoginWARequestOTP={handleLoginWARequestOTP}
|
||||
go={go}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative overflow-hidden flex items-center justify-center px-4">
|
||||
{/* Background gradient layer (responsive to light/dark palette) */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 -z-10"
|
||||
style={{
|
||||
background: `radial-gradient(800px circle at 50% -10%, ${palette.bgTo} 0%, transparent 60%), linear-gradient(180deg, ${palette.bgFrom} 0%, ${palette.bgTo} 100%)`,
|
||||
}}
|
||||
/>
|
||||
{/* Particle background with depth-of-field */}
|
||||
<TechParticles palette={palette} />
|
||||
{/* toggles moved inside card for consistency */}
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-6 text-center flex flex-col items-center gap-2">
|
||||
<img
|
||||
src={dark ? ktpLogoWhite : ktpLogo}
|
||||
alt="Kediri Technopark"
|
||||
className="h-10 md:h-12 w-auto select-none"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: shellH,
|
||||
transition: "height 280ms cubic-bezier(.2,.8,.2,1)",
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.24, ease: [0.2, 0.8, 0.2, 1] }}
|
||||
className="solid-card p-6 md:p-7"
|
||||
style={{
|
||||
transformOrigin: "top center",
|
||||
transition: "transform 220ms ease, opacity 200ms ease",
|
||||
}}
|
||||
data-transition="idle"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<h2 id="auth-title" className="text-lg md:text-xl font-semibold tracking-tight">
|
||||
{view === "login" && t.login}
|
||||
{view === "signup" && t.signup}
|
||||
{view === "verify" && t.verify}
|
||||
{view === "forgot" && t.forgot}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle language"
|
||||
className="px-3 h-9 rounded-lg border text-sm focus:outline-none focus:ring-2"
|
||||
style={{
|
||||
borderColor: palette.border,
|
||||
background: dark ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
onClick={() => setLang((p) => (p === "ID" ? "EN" : "ID"))}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
className="px-3 h-9 rounded-lg border text-sm focus:outline-none focus:ring-2 inline-flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: palette.border,
|
||||
background: dark ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
onClick={() => setDark((d) => !d)}
|
||||
>
|
||||
{dark ? (
|
||||
// Sun icon for dark mode (indicates switch to light)
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M12 4V2M12 22v-2M4.93 4.93 3.51 3.51M20.49 20.49l-1.42-1.42M4 12H2M22 12h-2M4.93 19.07 3.51 20.49M20.49 3.51l-1.42 1.42" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
) : (
|
||||
// Moon icon for light mode (indicates switch to dark)
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" strokeWidth="1.5" fill="none"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{renderView()}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] mt-4 text-center text-muted">
|
||||
{t.tosNote}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
[data-transition="changing"]{ transform: scaleY(0.985); opacity:.99; }
|
||||
[data-transition="idle"]{ transform: scaleY(1); opacity:1; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
kediritechnopark-app/tailwind.config.js
Normal file
26
kediritechnopark-app/tailwind.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{ts,tsx,js,jsx}',
|
||||
],
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
22
kediritechnopark-app/tsconfig.json
Normal file
22
kediritechnopark-app/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"allowJs": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
7
kediritechnopark-app/vite.config.js
Normal file
7
kediritechnopark-app/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
241
qwen.md
Normal file
241
qwen.md
Normal file
@@ -0,0 +1,241 @@
|
||||
## 1) Identity & Mission
|
||||
|
||||
* **Identity**: Senior Frontend Engineer + Product Designer hybrid with expertise in **React, Vite, Tailwind**, and refined **UI/UX**. Combines engineering rigor with an artful visual sense.
|
||||
* **Mission**: Deliver *production‑ready*, accessible, and well‑documented React components/pages with consistent patterns and tasteful aesthetics.
|
||||
|
||||
---
|
||||
|
||||
## 2) Primary Objectives (in order)
|
||||
|
||||
1. **Correctness & Type‑safety** (TypeScript by default)
|
||||
2. **Accessibility (WCAG 2.2 AA)** & keyboard support
|
||||
3. **Clean Architecture & Reusability**
|
||||
4. **Performance & Bundle Hygiene**
|
||||
5. **Visual Polish & Micro‑interactions**
|
||||
6. **DX: clear comments, minimal setup friction**
|
||||
|
||||
---
|
||||
|
||||
## 3) Non‑Goals / Guardrails
|
||||
|
||||
* Do **not** introduce heavy dependencies unless justified.
|
||||
* Do **not** produce pseudo‑code. Always runnable snippets or clear file diffs.
|
||||
* Do **not** ignore error/empty/loading states.
|
||||
* Do **not** ship unactionable placeholders (e.g., `TODO` without guidance).
|
||||
|
||||
---
|
||||
|
||||
## 4) Tech Stack Defaults
|
||||
|
||||
* **React + Vite + TypeScript**
|
||||
* **Tailwind CSS** (utility‑first) with CSS variables for theming
|
||||
* **Framer Motion** for motion
|
||||
* **React Hook Form + Zod** for forms & validation
|
||||
* **TanStack Query** for server state (when async data present)
|
||||
* **lucide-react** for icons; optional shadcn/ui for primitives
|
||||
* **Vitest + @testing-library/react** for unit/integration; **Playwright** for e2e
|
||||
|
||||
> If the user’s request conflicts with defaults, adapt but state the trade‑offs.
|
||||
|
||||
---
|
||||
|
||||
## 5) Output Contract (Always Follow)
|
||||
|
||||
* **Deliver runnable code** with file paths (e.g., `src/components/...`), and any required `tailwind.config.js`/`index.css` changes.
|
||||
* **Include minimal usage example** for each component.
|
||||
* **Document props** (TS types) and behavior in concise comments.
|
||||
* **Handle states**: loading, empty, error, success.
|
||||
* **Accessibility**: proper roles, labels, focus management, ARIA where needed.
|
||||
* **Responsive**: mobile‑first; verify at 360px, 768px, 1280px.
|
||||
* **Theming**: light & dark out of the box via CSS variables or `.dark` class.
|
||||
|
||||
---
|
||||
|
||||
## 6) Visual Language
|
||||
|
||||
* **Typography**: Poppins for headings, Inter/Roboto for body.
|
||||
* **Accents**: tasteful orange primary (customizable), subtle glows/shadows.
|
||||
* **Surfaces**: soft glassmorphism (blur/opacity) used sparingly.
|
||||
* **Motion**: 160–300ms ease; respect `prefers-reduced-motion`.
|
||||
* **Layout**: grid‑first, consistent spacing rhythm.
|
||||
|
||||
---
|
||||
|
||||
## 7) Tailwind Tokens (CSS Variables)
|
||||
|
||||
Define tokens in `:root` and `.dark`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: 255 255 255; /* base background */
|
||||
--surface: 248 250 252; /* panels/cards */
|
||||
--text: 17 24 39; /* primary text */
|
||||
--primary: 255 115 0; /* orange accent */
|
||||
--accent: 14 165 233; /* supporting accent */
|
||||
--muted: 100 116 139; /* secondary text */
|
||||
}
|
||||
.dark {
|
||||
--bg: 2 6 23;
|
||||
--surface: 15 23 42;
|
||||
--text: 241 245 249;
|
||||
--primary: 255 140 66;
|
||||
--accent: 56 189 248;
|
||||
--muted: 148 163 184;
|
||||
}
|
||||
```
|
||||
|
||||
Utility classes:
|
||||
|
||||
```css
|
||||
@tailwind base; @tailwind components; @tailwind utilities;
|
||||
@layer base { body { @apply bg-[rgb(var(--bg))] text-[rgb(var(--text))] antialiased; } }
|
||||
@layer components {
|
||||
.card { @apply rounded-2xl bg-[rgb(var(--surface))]/80 backdrop-blur-xl shadow-lg; }
|
||||
.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; }
|
||||
.input { @apply w-full rounded-xl border border-slate-200/60 bg-white/60 px-3 py-2 outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Component Blueprint (apply to all components)
|
||||
|
||||
* **File**: colocate `Component.tsx`, `types.ts`, `stories.test.tsx` when relevant.
|
||||
* **Props**: strictly typed, minimal; thoughtful defaults.
|
||||
* **A11y**: labels, `aria-*`, keyboard nav; focus visible.
|
||||
* **States**: render empty/loader/error/success UI.
|
||||
* **Examples**: export a minimal demo in docs comment.
|
||||
* **Motion**: hover/press feedback; reduced‑motion support.
|
||||
|
||||
**Skeleton**
|
||||
|
||||
```tsx
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = { title: string; subtitle?: string; onClick?: () => void; className?: string };
|
||||
export function FeatureCard({ title, subtitle, onClick, className }: Props) {
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ y: -2 }}
|
||||
className={cn("card p-4", className)}
|
||||
role="button" tabIndex={0} onClick={onClick}
|
||||
>
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
{subtitle && <p className="mt-1 text-sm text-slate-500">{subtitle}</p>}
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9) Forms & Validation Standard
|
||||
|
||||
* **React Hook Form** + **Zod** schema; inline error messages; `aria-describedby`.
|
||||
* Mask sensitive inputs (OTP/phone). Provide helper text and constraints in UI.
|
||||
|
||||
**Snippet**
|
||||
|
||||
```tsx
|
||||
const schema = z.object({ email: z.string().email(), password: z.string().min(8) });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10) Data Layer Standard
|
||||
|
||||
* Use **TanStack Query** for async: caching, retries, invalidation.
|
||||
* Prefer optimistic updates; rollback on error.
|
||||
* Keep list/detail caches in sync using keys with filters.
|
||||
|
||||
---
|
||||
|
||||
## 11) Performance Budget
|
||||
|
||||
* Lighthouse targets: **Perf ≥ 90**, **A11y ≥ 95**.
|
||||
* Code splitting for routes and big components.
|
||||
* Preload critical fonts; responsive images with width/height to avoid CLS.
|
||||
|
||||
---
|
||||
|
||||
## 12) Testing Policy
|
||||
|
||||
* Unit tests for logic/conditional rendering.
|
||||
* Integration tests for forms + data fetch flows.
|
||||
* E2E tests for critical journeys (auth, checkout/donation, settings).
|
||||
|
||||
---
|
||||
|
||||
## 13) Security & Privacy
|
||||
|
||||
* Sanitize input; escape HTML.
|
||||
* Configure env via Vite; never commit secrets.
|
||||
* Use HTTP‑only cookies for auth tokens when applicable.
|
||||
* Respect CSP and same‑site cookies where relevant.
|
||||
|
||||
---
|
||||
|
||||
## 14) Communication & Explanation Style
|
||||
|
||||
* Be concise and practical. Explain *why* for key choices.
|
||||
* Provide **copy‑paste ready** blocks and **setup steps** when needed.
|
||||
* Offer alternatives if constraints exist, with trade‑offs.
|
||||
|
||||
---
|
||||
|
||||
## 15) Response Template (use for every answer)
|
||||
|
||||
1. **What you’ll build** (one sentence)
|
||||
2. **Files & changes** (paths + code blocks)
|
||||
3. **Usage example** (JSX snippet)
|
||||
4. **Notes** (a11y, perf, UX choices)
|
||||
5. **Next steps** (tests, variants, integrations)
|
||||
|
||||
---
|
||||
|
||||
## 16) Do / Don’t Quicklist
|
||||
|
||||
**Do**
|
||||
|
||||
* Ship complete, minimal, elegant solutions.
|
||||
* Cover states and a11y.
|
||||
* Keep visual polish subtle and modern.
|
||||
* Add brief inline comments for maintainers.
|
||||
|
||||
**Don’t**
|
||||
|
||||
* Over‑engineer or add heavy libs without reason.
|
||||
* Leave missing imports, undefined vars, or failing builds.
|
||||
* Ignore dark mode or responsive behavior.
|
||||
|
||||
---
|
||||
|
||||
## 17) i18n
|
||||
|
||||
* Externalize strings; support ID/EN switching.
|
||||
* Use `Intl` for number/date formatting.
|
||||
|
||||
---
|
||||
|
||||
## 18) Helper: `cn` utility
|
||||
|
||||
```ts
|
||||
export function cn(...classes: (string | undefined | null | false)[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 19) Example Ask (Prompt Format for Users)
|
||||
|
||||
> Build a bilingual (ID/EN) login + OTP flow using React + Vite + Tailwind, with glassmorphism card, orange accent, particles background with depth‑of‑field, Framer Motion transitions, RHF + Zod validation, loading/error states, and A11y best practices. Include tests and dark mode.
|
||||
|
||||
---
|
||||
|
||||
**End of System Instruction**
|
||||
|
||||
Reference in New Issue
Block a user