feat: integrate 5 charity modules + scrollable desktop + dock autohide
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.vite/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
graphify-out/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
*.bak
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# ── Stage 1: build the React app ─────────────────────────
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 2: serve ────────────────────────────────────────
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/server.cjs ./server.cjs
|
||||||
|
|
||||||
|
COPY package-runtime.json ./package.json
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
CMD ["node", "server.cjs"]
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Falah OS — Community Edition</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='none' stroke='%23C9A84C' stroke-width='2'/%3E%3Cpath d='M16 2 A14 14 0 0 1 16 30 A10 10 0 0 0 16 10 A10 10 0 0 0 16 2' fill='%23C9A84C'/%3E%3C/svg%3E" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Cinzel:wght@400;600;700;900&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3559
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "falah-app-runtime",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "falah-app",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"description": "Falah OS CE — Desktop UI",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev:ui": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"start": "node server.cjs",
|
||||||
|
"dev": "node server.cjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'dist')));
|
||||||
|
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.json({ status: 'healthy', service: 'app', version: '1.3.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', service: 'app', msg: `running on port ${PORT}` }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_BASE || 'http://localhost:3000';
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'healthy', service: 'app', version: '1.3.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Falah OS — Wallet App</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #0A192F 0%, #112240 100%); min-height: 100vh; color: white; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 40px 20px; }
|
||||||
|
.header { text-align: center; margin-bottom: 60px; }
|
||||||
|
.logo { width: 80px; height: 80px; background: #C5A059; border-radius: 20px; display: inline-flex; align-items: center; justify-content: center; font-size: 40px; font-weight: bold; color: #0A192F; margin-bottom: 20px; }
|
||||||
|
h1 { font-size: 48px; margin-bottom: 10px; }
|
||||||
|
.subtitle { font-size: 20px; color: #C5A059; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 30px; margin-top: 40px; }
|
||||||
|
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 20px; padding: 30px; transition: all 0.3s; }
|
||||||
|
.card:hover { transform: translateY(-5px); border-color: #C5A059; }
|
||||||
|
.card h3 { font-size: 24px; margin-bottom: 15px; color: #C5A059; }
|
||||||
|
.card p { color: #8892b0; line-height: 1.6; }
|
||||||
|
.status { display: flex; align-items: center; gap: 10px; margin-top: 40px; padding: 20px; background: rgba(0,255,0,0.1); border-radius: 10px; }
|
||||||
|
.dot { width: 10px; height: 10px; background: #00ff88; border-radius: 50%; animation: pulse 2s infinite; }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||||
|
.services { margin-top: 40px; }
|
||||||
|
.services h2 { margin-bottom: 20px; }
|
||||||
|
.service-list { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.tag { background: rgba(197, 160, 89, 0.2); color: #C5A059; padding: 8px 16px; border-radius: 20px; font-size: 14px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">F</div>
|
||||||
|
<h1>Falah OS</h1>
|
||||||
|
<p class="subtitle">Sovereign Digital Economy Platform</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h3>🛡️ Ummah ID</h3>
|
||||||
|
<p>Zero-knowledge identity verification. Your device links to a verified sovereign citizen without a single byte of PII ever leaving your control.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>⚡ Wallet</h3>
|
||||||
|
<p>High-throughput, stateless routing engine. Create wallets, check balances, and initiate atomic transfers at Category A speed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>📜 RAMZ</h3>
|
||||||
|
<p>Pre-audited Shariah contracts as code. Qard al-Hasan, Mudarabah, and Zakat — plug-and-play primitives any developer can deploy.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>🧪 Mock-Net</h3>
|
||||||
|
<p>A complete digital twin of the Falah economy. Develop and test in a synthetic environment before touching mainnet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status">
|
||||||
|
<div class="dot"></div>
|
||||||
|
<span>All services operational — v1.3.0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="services">
|
||||||
|
<h2>Core Services</h2>
|
||||||
|
<div class="service-list">
|
||||||
|
<span class="tag">Ummah ID (3001)</span>
|
||||||
|
<span class="tag">Wallet (3002)</span>
|
||||||
|
<span class="tag">RAMZ (3003)</span>
|
||||||
|
<span class="tag">Mock-Net (3004)</span>
|
||||||
|
<span class="tag">API Gateway (3000)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`👛 Wallet App running on port ${PORT}`);
|
||||||
|
console.log(` Open http://localhost:${PORT} in your browser`);
|
||||||
|
});
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useApp } from './store'
|
||||||
|
import Onboarding from './screens/Onboarding'
|
||||||
|
import Login from './screens/Login'
|
||||||
|
import Desktop from './screens/Desktop'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { screen, wallpaper, closeOverlay } = useApp()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeOverlay()
|
||||||
|
// '/' is handled inside Desktop
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [closeOverlay])
|
||||||
|
|
||||||
|
const wallpaperClass = wallpaper === 'default' ? 'wallpaper' : `wallpaper wp-${wallpaper}`
|
||||||
|
|
||||||
|
const screenStyle = (active: boolean): React.CSSProperties => ({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
opacity: active ? 1 : 0,
|
||||||
|
pointerEvents: active ? 'auto' : 'none',
|
||||||
|
transition: 'opacity 0.4s, transform 0.4s',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<div className={wallpaperClass} />
|
||||||
|
|
||||||
|
<div style={screenStyle(screen === 'onboarding')}>
|
||||||
|
<Onboarding />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={screenStyle(screen === 'login')}>
|
||||||
|
<Login />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={screenStyle(screen === 'desktop')}>
|
||||||
|
<Desktop />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API, apiFetch } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type Tab = 'certificates' | 'verify'
|
||||||
|
|
||||||
|
interface ZKProof { proofId: string; identityId: string; verified: boolean; issuedAt: string }
|
||||||
|
|
||||||
|
const ADMIN_SECRET = 'dd8GU158UJaL4krWWlxWq4uey1AhzmSzNolT10e4lXtC3rBT'
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
tab: (active: boolean): React.CSSProperties => ({
|
||||||
|
flex: 1, padding: '7px 0', fontSize: 12, fontWeight: active ? 600 : 400,
|
||||||
|
color: active ? '#10B981' : 'var(--text-muted)',
|
||||||
|
background: active ? 'rgba(16,185,129,0.12)' : 'transparent',
|
||||||
|
borderRadius: 6, cursor: 'pointer', border: active ? '1px solid rgba(16,185,129,0.25)' : '1px solid transparent',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}),
|
||||||
|
input: { width: '100%', padding: '8px 10px', borderRadius: 8, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text)', fontSize: 13 } as React.CSSProperties,
|
||||||
|
btn: (disabled?: boolean): React.CSSProperties => ({
|
||||||
|
padding: '8px 16px', borderRadius: 8, background: '#10B981', color: '#fff', fontSize: 13, fontWeight: 600, cursor: disabled ? 'not-allowed' : 'pointer', border: 'none', opacity: disabled ? 0.5 : 1,
|
||||||
|
}),
|
||||||
|
card: { background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, padding: '10px 12px', marginBottom: 8 } as React.CSSProperties,
|
||||||
|
err: { color: '#EF4444', fontSize: 12, marginTop: 6 } as React.CSSProperties,
|
||||||
|
muted: { color: 'var(--text-muted)', fontSize: 12 } as React.CSSProperties,
|
||||||
|
badge: (ok: boolean): React.CSSProperties => ({
|
||||||
|
display: 'inline-block', padding: '2px 8px', borderRadius: 99, fontSize: 11, fontWeight: 600,
|
||||||
|
background: ok ? 'rgba(16,185,129,0.18)' : 'rgba(239,68,68,0.18)',
|
||||||
|
color: ok ? '#34D399' : '#F87171',
|
||||||
|
border: `1px solid ${ok ? 'rgba(16,185,129,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n = 18) { return s.length > n ? s.slice(0, n) + '…' : s }
|
||||||
|
function fmtDate(s: string) {
|
||||||
|
try { return new Date(s).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) }
|
||||||
|
catch { return s }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CertsApp() {
|
||||||
|
const { authToken } = useApp()
|
||||||
|
const [tab, setTab] = useState<Tab>('certificates')
|
||||||
|
|
||||||
|
const [proofs, setProofs] = useState<ZKProof[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [issuing, setIssuing] = useState(false)
|
||||||
|
const [issueMsg, setIssueMsg] = useState('')
|
||||||
|
|
||||||
|
const [verifyInput, setVerifyInput] = useState('')
|
||||||
|
const [verifyResult, setVerifyResult] = useState<'idle' | 'found' | 'notfound'>('idle')
|
||||||
|
|
||||||
|
const loadProofs = useCallback(async () => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<ZKProof[]>(`${API.ummahid}/api/admin/zkproofs`, undefined, {
|
||||||
|
headers: { 'x-admin-secret': ADMIN_SECRET },
|
||||||
|
})
|
||||||
|
setProofs(Array.isArray(data) ? data : [])
|
||||||
|
} catch (e) { setErr((e as Error).message) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadProofs() }, [loadProofs])
|
||||||
|
|
||||||
|
const issueCert = async () => {
|
||||||
|
if (!authToken) return
|
||||||
|
setIssuing(true); setIssueMsg('')
|
||||||
|
try {
|
||||||
|
const deviceId = 'demo-device-' + Date.now()
|
||||||
|
const identity = await apiFetch<{ identityId?: string; id?: string }>(
|
||||||
|
`${API.ummahid}/api/identity/create`, authToken,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ deviceId }) },
|
||||||
|
)
|
||||||
|
const resolvedId = identity.identityId ?? identity.id ?? deviceId
|
||||||
|
await apiFetch(
|
||||||
|
`${API.ummahid}/api/identity/verify`, authToken,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ deviceId: resolvedId, zkProof: 'sha256-halal-proof-' + Date.now() }) },
|
||||||
|
)
|
||||||
|
setIssueMsg('Certificate issued successfully.')
|
||||||
|
await loadProofs()
|
||||||
|
} catch (e) { setIssueMsg('Error: ' + (e as Error).message) }
|
||||||
|
finally { setIssuing(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVerify = () => {
|
||||||
|
if (!verifyInput.trim()) return
|
||||||
|
const hit = proofs.find(p => p.proofId.startsWith(verifyInput.trim()))
|
||||||
|
setVerifyResult(hit ? 'found' : 'notfound')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="certs" title="Halal Certs" icon="🏅" width={420} height={480}>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
|
||||||
|
{(['certificates', 'verify'] as Tab[]).map(t => (
|
||||||
|
<button key={t} style={S.tab(tab === t)} onClick={() => setTab(t)}>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificates tab */}
|
||||||
|
{tab === 'certificates' && (
|
||||||
|
<div>
|
||||||
|
{loading && <p style={S.muted}>Loading...</p>}
|
||||||
|
{err && <p style={S.err}>{err}</p>}
|
||||||
|
|
||||||
|
<div style={{ maxHeight: 250, overflowY: 'auto', marginBottom: 14 }}>
|
||||||
|
{!loading && proofs.length === 0 && !err && (
|
||||||
|
<p style={{ ...S.muted, textAlign: 'center', padding: '24px 0' }}>No certificates issued yet</p>
|
||||||
|
)}
|
||||||
|
{proofs.map(p => (
|
||||||
|
<div key={p.proofId} style={S.card}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600 }}>{truncate(p.proofId, 22)}</span>
|
||||||
|
<span style={S.badge(p.verified)}>{p.verified ? '✓ Verified' : '✗ Unverified'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={S.muted}>ID: {truncate(p.identityId, 20)}</div>
|
||||||
|
<div style={S.muted}>{fmtDate(p.issuedAt)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authToken && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.07)', paddingTop: 12 }}>
|
||||||
|
<button style={S.btn(issuing)} onClick={issueCert} disabled={issuing}>
|
||||||
|
{issuing ? 'Issuing…' : 'Issue Demo Certificate'}
|
||||||
|
</button>
|
||||||
|
{issueMsg && (
|
||||||
|
<p style={{ fontSize: 12, marginTop: 6, color: issueMsg.startsWith('Error') ? '#EF4444' : '#34D399' }}>
|
||||||
|
{issueMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verify tab */}
|
||||||
|
{tab === 'verify' && (
|
||||||
|
<div>
|
||||||
|
<p style={{ ...S.muted, marginBottom: 12 }}>Enter a Proof ID prefix to verify it on-chain.</p>
|
||||||
|
<input
|
||||||
|
placeholder="Enter Proof ID…"
|
||||||
|
value={verifyInput}
|
||||||
|
onChange={e => { setVerifyInput(e.target.value); setVerifyResult('idle') }}
|
||||||
|
style={{ ...S.input, marginBottom: 10 }}
|
||||||
|
/>
|
||||||
|
<button style={S.btn(!verifyInput.trim())} onClick={handleVerify} disabled={!verifyInput.trim()}>
|
||||||
|
Verify
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{verifyResult === 'found' && (
|
||||||
|
<div style={{ marginTop: 16, ...S.card, borderColor: 'rgba(16,185,129,0.3)' }}>
|
||||||
|
<span style={{ color: '#34D399', fontWeight: 600 }}>✓ Verified</span>
|
||||||
|
<p style={S.muted}>Proof found in registry.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{verifyResult === 'notfound' && (
|
||||||
|
<div style={{ marginTop: 16, ...S.card, borderColor: 'rgba(239,68,68,0.3)' }}>
|
||||||
|
<span style={{ color: '#F87171', fontWeight: 600 }}>✗ Not Found</span>
|
||||||
|
<p style={S.muted}>No matching proof in registry.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API, apiFetch } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
interface Channel { id: string; name: string; description: string; type: string; messageCount: number }
|
||||||
|
interface Message { id: string; channelId: string; authorId: string; authorName: string; content: string; createdAt: string }
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
input: { flex: 1, padding: '8px 11px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(255,255,255,0.06)', color: 'var(--text)', fontSize: '12px', fontFamily: 'inherit', outline: 'none', minWidth: 0 } as React.CSSProperties,
|
||||||
|
btn: { padding: '8px 14px', borderRadius: '8px', border: 'none', background: '#10B981', color: '#fff', fontSize: '12px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' as const } as React.CSSProperties,
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string) {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatApp() {
|
||||||
|
const { authToken, authUser } = useApp()
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([])
|
||||||
|
const [activeChannel, setActiveChannel] = useState<string>('general')
|
||||||
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [typingUsers, setTypingUsers] = useState<string[]>([])
|
||||||
|
const [newChanMode, setNewChanMode] = useState(false)
|
||||||
|
const [newChanName, setNewChanName] = useState('')
|
||||||
|
|
||||||
|
const ws = useRef<WebSocket | null>(null)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const typingTimeout = useRef<number | null>(null)
|
||||||
|
const activeChannelRef = useRef(activeChannel)
|
||||||
|
|
||||||
|
// Keep ref in sync with state
|
||||||
|
useEffect(() => { activeChannelRef.current = activeChannel }, [activeChannel])
|
||||||
|
|
||||||
|
// Auto-scroll on new messages
|
||||||
|
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
||||||
|
|
||||||
|
// Fetch channels on mount
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<Channel[]>(`${API.chat}/api/channels`, authToken || undefined)
|
||||||
|
.then(setChannels).catch(() => {})
|
||||||
|
}, [authToken])
|
||||||
|
|
||||||
|
// WebSocket lifecycle
|
||||||
|
useEffect(() => {
|
||||||
|
const host = window.location.hostname
|
||||||
|
const socket = new WebSocket(`ws://${host}:3009/ws`)
|
||||||
|
ws.current = socket
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
setConnected(true)
|
||||||
|
if (authToken) socket.send(JSON.stringify({ type: 'auth', token: authToken }))
|
||||||
|
socket.send(JSON.stringify({ type: 'join', channelId: 'general' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.type === 'joined') {
|
||||||
|
setMessages(data.recentMessages || [])
|
||||||
|
}
|
||||||
|
if (data.type === 'message') {
|
||||||
|
if (data.channelId === activeChannelRef.current) {
|
||||||
|
setMessages(prev => [...prev, data.message])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.type === 'typing') {
|
||||||
|
if (data.userId !== authUser?.id) {
|
||||||
|
setTypingUsers(prev => [...new Set([...prev, data.username])])
|
||||||
|
setTimeout(() => setTypingUsers(prev => prev.filter(u => u !== data.username)), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => setConnected(false)
|
||||||
|
socket.onerror = () => setConnected(false)
|
||||||
|
|
||||||
|
return () => socket.close()
|
||||||
|
}, [authToken]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const switchChannel = useCallback((channelId: string) => {
|
||||||
|
setActiveChannel(channelId)
|
||||||
|
activeChannelRef.current = channelId
|
||||||
|
setMessages([])
|
||||||
|
if (ws.current?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.current.send(JSON.stringify({ type: 'join', channelId }))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const sendMessage = useCallback(async () => {
|
||||||
|
const content = input.trim()
|
||||||
|
if (!content) return
|
||||||
|
setInput('')
|
||||||
|
if (connected && ws.current?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.current.send(JSON.stringify({ type: 'message', channelId: activeChannel, content }))
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const msg = await apiFetch<Message>(
|
||||||
|
`${API.chat}/api/channels/${activeChannel}/messages`,
|
||||||
|
authToken || undefined,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ content }) }
|
||||||
|
)
|
||||||
|
setMessages(prev => [...prev, msg])
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
}, [input, connected, activeChannel, authToken])
|
||||||
|
|
||||||
|
const handleInputChange = (val: string) => {
|
||||||
|
setInput(val)
|
||||||
|
if (ws.current?.readyState === WebSocket.OPEN && authToken) {
|
||||||
|
if (typingTimeout.current) return
|
||||||
|
ws.current.send(JSON.stringify({ type: 'typing', channelId: activeChannel }))
|
||||||
|
typingTimeout.current = window.setTimeout(() => { typingTimeout.current = null }, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createChannel = async () => {
|
||||||
|
const name = newChanName.trim()
|
||||||
|
if (!name || !authToken) return
|
||||||
|
try {
|
||||||
|
const ch = await apiFetch<Channel>(
|
||||||
|
`${API.chat}/api/channels`,
|
||||||
|
authToken,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ name, type: 'PUBLIC', description: '' }) }
|
||||||
|
)
|
||||||
|
setChannels(prev => [...prev, ch])
|
||||||
|
setNewChanName('')
|
||||||
|
setNewChanMode(false)
|
||||||
|
switchChannel(ch.id)
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeChan = channels.find(c => c.id === activeChannel || c.name === activeChannel)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="chat" title="Chat" icon="💬" width={540} height={560}>
|
||||||
|
{/* Reconnecting banner */}
|
||||||
|
{!connected && (
|
||||||
|
<div style={{ background: 'rgba(251,146,60,0.15)', border: '1px solid rgba(251,146,60,0.3)', borderRadius: '7px', padding: '6px 12px', marginBottom: '10px', fontSize: '11px', color: '#FB923C', textAlign: 'center' }}>
|
||||||
|
Reconnecting…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 0, height: connected ? 'calc(100% - 0px)' : 'calc(100% - 38px)', overflow: 'hidden', borderRadius: '10px', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
|
{/* Left: channel list */}
|
||||||
|
<div style={{ width: 150, flexShrink: 0, background: 'rgba(0,0,0,0.2)', borderRight: '1px solid rgba(255,255,255,0.06)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '10px 12px 6px', fontSize: '10px', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.8px' }}>Channels</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{channels.map(ch => {
|
||||||
|
const isActive = ch.id === activeChannel || ch.name === activeChannel
|
||||||
|
return (
|
||||||
|
<div key={ch.id} onClick={() => switchChannel(ch.id)}
|
||||||
|
style={{ padding: '7px 12px', cursor: 'pointer', fontSize: '12px', color: isActive ? 'var(--text)' : 'var(--text-muted)', background: isActive ? 'rgba(255,255,255,0.06)' : 'transparent', borderLeft: isActive ? '2px solid #10B981' : '2px solid transparent', transition: 'background 0.1s', userSelect: 'none' }}>
|
||||||
|
# {ch.name}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New channel */}
|
||||||
|
{authToken && (
|
||||||
|
<div style={{ padding: '8px 10px', borderTop: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
{newChanMode ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
||||||
|
<input autoFocus style={{ ...S.input, fontSize: '11px', padding: '5px 8px' }} placeholder="channel-name" value={newChanName}
|
||||||
|
onChange={e => setNewChanName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') createChannel(); if (e.key === 'Escape') { setNewChanMode(false); setNewChanName('') } }} />
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<button onClick={createChannel} style={{ ...S.btn, flex: 1, padding: '4px', fontSize: '11px' }}>Add</button>
|
||||||
|
<button onClick={() => { setNewChanMode(false); setNewChanName('') }} style={{ ...S.btn, flex: 1, padding: '4px', fontSize: '11px', background: 'rgba(255,255,255,0.08)' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setNewChanMode(true)} style={{ width: '100%', padding: '5px 8px', borderRadius: '7px', border: '1px dashed rgba(255,255,255,0.15)', background: 'transparent', color: 'var(--text-muted)', fontSize: '11px', cursor: 'pointer', fontFamily: 'inherit' }}>+ New</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connection status */}
|
||||||
|
<div style={{ padding: '6px 12px 10px', display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||||
|
<div style={{ width: 7, height: 7, borderRadius: '50%', background: connected ? '#10B981' : '#EF4444', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: '10px', color: 'var(--text-dim)' }}>{connected ? 'Connected' : 'Not connected'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: messages + input */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{/* Channel header */}
|
||||||
|
<div style={{ padding: '10px 14px', borderBottom: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)' }}># {activeChan?.name ?? activeChannel}</div>
|
||||||
|
{activeChan?.description && <div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>{activeChan.description}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages scroll area */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={{ margin: 'auto', textAlign: 'center', color: 'var(--text-muted)', fontSize: '12px', lineHeight: 1.6 }}>
|
||||||
|
No messages yet.<br />Be the first to say salam!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg, i) => {
|
||||||
|
const prev = messages[i - 1]
|
||||||
|
const grouped = prev?.authorId === msg.authorId
|
||||||
|
const isMine = msg.authorId === authUser?.id
|
||||||
|
return (
|
||||||
|
<div key={msg.id} style={{ paddingTop: grouped ? 0 : '8px' }}>
|
||||||
|
{!grouped && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '7px', marginBottom: '2px' }}>
|
||||||
|
<span style={{ fontSize: '12px', fontWeight: 700, color: isMine ? '#10B981' : 'var(--text)' }}>{msg.authorName}</span>
|
||||||
|
<span style={{ fontSize: '10px', color: 'var(--text-dim)' }}>{fmtTime(msg.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text)', lineHeight: 1.5, paddingLeft: grouped ? '0' : '0' }}>{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{typingUsers.length > 0 && (
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', fontStyle: 'italic', marginTop: '4px' }}>
|
||||||
|
{typingUsers.join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input row */}
|
||||||
|
{!authToken ? (
|
||||||
|
<div style={{ padding: '10px 14px', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '11px', color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||||
|
Log in via Ummah ID to send messages. You can still read.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '10px 14px', borderTop: '1px solid rgba(255,255,255,0.06)', display: 'flex', gap: '8px', flexShrink: 0 }}>
|
||||||
|
<input
|
||||||
|
style={S.input}
|
||||||
|
placeholder="Type a message…"
|
||||||
|
value={input}
|
||||||
|
onChange={e => handleInputChange(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') sendMessage() }}
|
||||||
|
/>
|
||||||
|
<button style={{ ...S.btn, opacity: !input.trim() ? 0.5 : 1 }} disabled={!input.trim()} onClick={sendMessage}>Send</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API, apiFetch } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type CommunityType = 'MOSQUE' | 'MADRASAH' | 'COLLEGE' | 'NEIGHBOURHOOD' | 'MERCHANT_GUILD'
|
||||||
|
type Tab = 'discover' | 'feed' | 'events' | 'create' | 'hub'
|
||||||
|
type CreateMode = 'community' | 'event'
|
||||||
|
|
||||||
|
interface Community { id: string; name: string; type: CommunityType; description: string; location: string; memberCount: number; createdAt: string }
|
||||||
|
interface Announcement { id: string; communityId: string; authorId: string; title: string; content: string; pinned: boolean; category: string; createdAt: string; communityName?: string }
|
||||||
|
interface CommunityEvent { id: string; communityId: string; title: string; description: string; startAt: string; endAt: string; location: string; type: string; communityName?: string }
|
||||||
|
|
||||||
|
const TYPE_ICON: Record<CommunityType, string> = { MOSQUE: '🕌', MADRASAH: '📖', COLLEGE: '🎓', NEIGHBOURHOOD: '🏘️', MERCHANT_GUILD: '🏪' }
|
||||||
|
const EVENT_ICON: Record<string, string> = { SALAH: '🕌', LECTURE: '📚', FUNDRAISER: '💰', MEETING: '🤝', COURSE: '🎓', MARKET: '🛒', SOCIAL: '🎉' }
|
||||||
|
const CAT_COLOR: Record<string, string> = { GENERAL: '#64748B', PRAYER: '#10B981', EVENT: '#0284C7', URGENT: '#EF4444' }
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
card: { background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '10px', padding: '12px 14px' } as React.CSSProperties,
|
||||||
|
input: { width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.06)', color: 'var(--text)', fontSize: '12px', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' as const },
|
||||||
|
label: { fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' as const, letterSpacing: '0.6px', display: 'block', marginBottom: '4px' },
|
||||||
|
btn: { padding: '9px 18px', borderRadius: '8px', border: 'none', background: '#10B981', color: '#fff', fontSize: '12px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' } as React.CSSProperties,
|
||||||
|
pill: (active: boolean): React.CSSProperties => ({ padding: '6px 14px', borderRadius: '99px', border: 'none', background: active ? '#10B981' : 'rgba(255,255,255,0.06)', color: active ? '#fff' : 'var(--text-muted)', fontSize: '12px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }),
|
||||||
|
scroll: { overflowY: 'auto' as const, maxHeight: '360px', display: 'flex', flexDirection: 'column' as const, gap: '8px' },
|
||||||
|
lock: { display: 'flex', flexDirection: 'column' as const, alignItems: 'center', justifyContent: 'center', height: '240px', gap: '10px', color: 'var(--text-muted)', textAlign: 'center' as const },
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string) {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const m = Math.floor(diff / 60000)
|
||||||
|
if (m < 1) return 'just now'
|
||||||
|
if (m < 60) return `${m}m ago`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h < 24) return `${h}h ago`
|
||||||
|
return `${Math.floor(h / 24)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso: string) {
|
||||||
|
try { return new Date(iso).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) }
|
||||||
|
catch { return iso }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommunityApp() {
|
||||||
|
const { authToken } = useApp()
|
||||||
|
const [tab, setTab] = useState<Tab>('discover')
|
||||||
|
const [communities, setCommunities] = useState<Community[]>([])
|
||||||
|
const [joined, setJoined] = useState<Set<string>>(new Set())
|
||||||
|
const [feed, setFeed] = useState<(Announcement | CommunityEvent)[]>([])
|
||||||
|
const [events, setEvents] = useState<CommunityEvent[]>([])
|
||||||
|
const [rsvped, setRsvped] = useState<Set<string>>(new Set())
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [createMode, setCreateMode] = useState<CreateMode>('community')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
|
||||||
|
// Create Community form
|
||||||
|
const [cName, setCName] = useState('')
|
||||||
|
const [cType, setCType] = useState<CommunityType>('MOSQUE')
|
||||||
|
const [cDesc, setCDesc] = useState('')
|
||||||
|
const [cLoc, setCLoc] = useState('')
|
||||||
|
|
||||||
|
// Create Event form
|
||||||
|
const [eCommunity, setECommunity] = useState('')
|
||||||
|
const [eTitle, setETitle] = useState('')
|
||||||
|
const [eType, setEType] = useState('SALAH')
|
||||||
|
const [eStart, setEStart] = useState('')
|
||||||
|
const [eEnd, setEEnd] = useState('')
|
||||||
|
const [eLoc, setELoc] = useState('')
|
||||||
|
const [formErr, setFormErr] = useState('')
|
||||||
|
|
||||||
|
const loadCommunities = useCallback(async () => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<Community[]>(`${API.community}/api/communities`)
|
||||||
|
setCommunities(Array.isArray(data) ? data : [])
|
||||||
|
} catch (e) { setErr((e as Error).message) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadFeed = useCallback(async () => {
|
||||||
|
if (!authToken) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const all: (Announcement | CommunityEvent)[] = []
|
||||||
|
for (const c of communities.slice(0, 5)) {
|
||||||
|
try {
|
||||||
|
const items = await apiFetch<(Announcement | CommunityEvent)[]>(`${API.community}/api/communities/${c.id}/feed`, authToken)
|
||||||
|
const tagged = (Array.isArray(items) ? items : []).map(i => ({ ...i, communityName: c.name }))
|
||||||
|
all.push(...tagged)
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
all.sort((a, b) => new Date((b as Announcement).createdAt ?? (b as CommunityEvent).startAt).getTime() - new Date((a as Announcement).createdAt ?? (a as CommunityEvent).startAt).getTime())
|
||||||
|
setFeed(all)
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [authToken, communities])
|
||||||
|
|
||||||
|
const loadEvents = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<CommunityEvent[]>(`${API.community}/api/events`, authToken ?? undefined)
|
||||||
|
const list = Array.isArray(data) ? data : []
|
||||||
|
const withNames = list.map(ev => ({ ...ev, communityName: communities.find(c => c.id === ev.communityId)?.name ?? '' }))
|
||||||
|
setEvents(withNames)
|
||||||
|
} catch (e) { setErr((e as Error).message) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [authToken, communities])
|
||||||
|
|
||||||
|
useEffect(() => { loadCommunities() }, [loadCommunities])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'feed' && communities.length) loadFeed()
|
||||||
|
if (tab === 'events' && communities.length) loadEvents()
|
||||||
|
}, [tab, communities, loadFeed, loadEvents])
|
||||||
|
|
||||||
|
const handleJoin = async (id: string) => {
|
||||||
|
if (!authToken) return
|
||||||
|
try {
|
||||||
|
await apiFetch(`${API.community}/api/communities/${id}/join`, authToken, { method: 'POST' })
|
||||||
|
setJoined(prev => new Set([...prev, id]))
|
||||||
|
} catch (e) { setErr((e as Error).message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRsvp = async (id: string) => {
|
||||||
|
if (!authToken) return
|
||||||
|
try {
|
||||||
|
await apiFetch(`${API.community}/api/events/${id}/rsvp`, authToken, { method: 'POST' })
|
||||||
|
setRsvped(prev => new Set([...prev, id]))
|
||||||
|
} catch (e) { setErr((e as Error).message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateCommunity = async () => {
|
||||||
|
setFormErr(''); setSuccess('')
|
||||||
|
if (!cName.trim() || !cDesc.trim() || !cLoc.trim()) { setFormErr('All fields required'); return }
|
||||||
|
try {
|
||||||
|
await apiFetch(`${API.community}/api/communities`, authToken!, { method: 'POST', body: JSON.stringify({ name: cName, type: cType, description: cDesc, location: cLoc }) })
|
||||||
|
setSuccess('Community created!'); setCName(''); setCDesc(''); setCLoc('')
|
||||||
|
loadCommunities(); setTimeout(() => setTab('discover'), 1200)
|
||||||
|
} catch (e) { setFormErr((e as Error).message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateEvent = async () => {
|
||||||
|
setFormErr(''); setSuccess('')
|
||||||
|
if (!eCommunity || !eTitle.trim() || !eStart || !eEnd || !eLoc.trim()) { setFormErr('All fields required'); return }
|
||||||
|
try {
|
||||||
|
await apiFetch(`${API.community}/api/events`, authToken!, { method: 'POST', body: JSON.stringify({ communityId: eCommunity, title: eTitle, type: eType, startAt: eStart, endAt: eEnd, location: eLoc }) })
|
||||||
|
setSuccess('Event created!'); setETitle(''); setEStart(''); setEEnd(''); setELoc('')
|
||||||
|
setTimeout(() => setTab('events'), 1200)
|
||||||
|
} catch (e) { setFormErr((e as Error).message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [{ key: 'discover', label: 'Discover' }, { key: 'feed', label: 'My Feed' }, { key: 'events', label: 'Events' }, { key: 'create', label: 'Create' }, { key: 'hub', label: 'Hub' }]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="community" title="Community" icon="🕌" width={520} height={560}>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '14px', flexWrap: 'wrap' }}>
|
||||||
|
{TABS.map(t => <button key={t.key} style={S.pill(tab === t.key)} onClick={() => { setErr(''); setTab(t.key) }}>{t.label}</button>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div style={{ fontSize: '12px', color: '#F87171', marginBottom: '10px' }}>{err}</div>}
|
||||||
|
|
||||||
|
{/* DISCOVER */}
|
||||||
|
{tab === 'discover' && (
|
||||||
|
<div style={S.scroll}>
|
||||||
|
{loading && <div style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Loading…</div>}
|
||||||
|
{!loading && communities.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '13px', textAlign: 'center', padding: '40px 0' }}>No communities yet — be the first to create one!</div>}
|
||||||
|
{communities.map(c => (
|
||||||
|
<div key={c.id} style={S.card}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||||
|
<span style={{ fontSize: '22px' }}>{TYPE_ICON[c.type] ?? '🏛️'}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)' }}>{c.name}</span>
|
||||||
|
<span style={{ fontSize: '10px', fontWeight: 600, padding: '2px 7px', borderRadius: '99px', background: 'rgba(255,255,255,0.08)', color: 'var(--text-muted)' }}>{c.type.replace('_', ' ')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', margin: '3px 0' }}>{c.description.length > 60 ? c.description.slice(0, 60) + '…' : c.description}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>📍 {c.location} · 👥 {c.memberCount}</div>
|
||||||
|
</div>
|
||||||
|
{authToken
|
||||||
|
? <button onClick={() => handleJoin(c.id)} disabled={joined.has(c.id)} style={{ ...S.btn, padding: '6px 12px', fontSize: '11px', flexShrink: 0, opacity: joined.has(c.id) ? 0.7 : 1, background: joined.has(c.id) ? 'rgba(16,185,129,0.3)' : '#10B981' }}>{joined.has(c.id) ? 'Joined ✓' : 'Join'}</button>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
{!authToken && <div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '6px' }}>Log in via Ummah ID to join</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MY FEED */}
|
||||||
|
{tab === 'feed' && !authToken && (
|
||||||
|
<div style={S.lock}><span style={{ fontSize: '32px' }}>🔒</span><div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text)' }}>Login Required</div><div style={{ fontSize: '12px' }}>Open Ummah ID app and log in first</div></div>
|
||||||
|
)}
|
||||||
|
{tab === 'feed' && authToken && (
|
||||||
|
<div style={S.scroll}>
|
||||||
|
{loading && <div style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Loading…</div>}
|
||||||
|
{!loading && feed.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '13px', textAlign: 'center', padding: '40px 0' }}>No announcements yet</div>}
|
||||||
|
{feed.map((item, i) => {
|
||||||
|
const ann = item as Announcement
|
||||||
|
const cat = ann.category ?? 'GENERAL'
|
||||||
|
return (
|
||||||
|
<div key={ann.id ?? i} style={S.card}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||||
|
<span style={{ fontSize: '10px', fontWeight: 600, padding: '2px 7px', borderRadius: '99px', background: CAT_COLOR[cat] ?? '#64748B', color: '#fff' }}>{cat}</span>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{ann.communityName}</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: '11px', color: 'var(--text-muted)' }}>{timeAgo((ann as Announcement).createdAt ?? (ann as unknown as CommunityEvent).startAt ?? '')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)', marginBottom: '3px' }}>{ann.title}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', lineHeight: 1.4 }}>{(ann as Announcement).content ?? (ann as unknown as CommunityEvent).description ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* EVENTS */}
|
||||||
|
{tab === 'events' && (
|
||||||
|
<div style={S.scroll}>
|
||||||
|
{loading && <div style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Loading…</div>}
|
||||||
|
{!loading && events.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '13px', textAlign: 'center', padding: '40px 0' }}>No upcoming events</div>}
|
||||||
|
{events.map(ev => (
|
||||||
|
<div key={ev.id} style={{ ...S.card, display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||||
|
<span style={{ fontSize: '20px' }}>{EVENT_ICON[ev.type] ?? '📅'}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)' }}>{ev.title}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', margin: '3px 0' }}>{fmtDateTime(ev.startAt)} · 📍 {ev.location}</div>
|
||||||
|
{ev.communityName && <div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{ev.communityName}</div>}
|
||||||
|
</div>
|
||||||
|
{authToken && <button onClick={() => handleRsvp(ev.id)} disabled={rsvped.has(ev.id)} style={{ ...S.btn, padding: '5px 10px', fontSize: '11px', flexShrink: 0, opacity: rsvped.has(ev.id) ? 0.7 : 1, background: rsvped.has(ev.id) ? 'rgba(16,185,129,0.3)' : '#10B981' }}>{rsvped.has(ev.id) ? 'RSVPd ✓' : 'RSVP'}</button>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CREATE */}
|
||||||
|
{tab === 'create' && !authToken && (
|
||||||
|
<div style={S.lock}><span style={{ fontSize: '32px' }}>🔒</span><div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text)' }}>Login Required</div><div style={{ fontSize: '12px' }}>Open Ummah ID app and log in first</div></div>
|
||||||
|
)}
|
||||||
|
{tab === 'create' && authToken && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
{(['community', 'event'] as CreateMode[]).map(m => <button key={m} style={S.pill(createMode === m)} onClick={() => { setCreateMode(m); setFormErr(''); setSuccess('') }}>{m === 'community' ? 'Create Community' : 'Create Event'}</button>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && <div style={{ fontSize: '12px', color: '#10B981', background: 'rgba(16,185,129,0.08)', borderRadius: '8px', padding: '8px 12px' }}>{success}</div>}
|
||||||
|
{formErr && <div style={{ fontSize: '12px', color: '#F87171' }}>{formErr}</div>}
|
||||||
|
|
||||||
|
{createMode === 'community' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<div><label style={S.label}>Name</label><input style={S.input} value={cName} onChange={e => setCName(e.target.value)} placeholder="Community name" /></div>
|
||||||
|
<div><label style={S.label}>Type</label>
|
||||||
|
<select style={{ ...S.input }} value={cType} onChange={e => setCType(e.target.value as CommunityType)}>
|
||||||
|
{(['MOSQUE', 'MADRASAH', 'COLLEGE', 'NEIGHBOURHOOD', 'MERCHANT_GUILD'] as CommunityType[]).map(t => <option key={t} value={t}>{t.replace('_', ' ')}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><label style={S.label}>Description</label><textarea style={{ ...S.input, resize: 'vertical', minHeight: '60px' }} value={cDesc} onChange={e => setCDesc(e.target.value)} placeholder="Brief description" /></div>
|
||||||
|
<div><label style={S.label}>Location</label><input style={S.input} value={cLoc} onChange={e => setCLoc(e.target.value)} placeholder="City / area" /></div>
|
||||||
|
<button style={S.btn} onClick={handleCreateCommunity}>Create Community</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createMode === 'event' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<div><label style={S.label}>Community</label>
|
||||||
|
<select style={{ ...S.input }} value={eCommunity} onChange={e => setECommunity(e.target.value)}>
|
||||||
|
<option value="">Select community…</option>
|
||||||
|
{communities.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><label style={S.label}>Title</label><input style={S.input} value={eTitle} onChange={e => setETitle(e.target.value)} placeholder="Event title" /></div>
|
||||||
|
<div><label style={S.label}>Type</label>
|
||||||
|
<select style={{ ...S.input }} value={eType} onChange={e => setEType(e.target.value)}>
|
||||||
|
{['SALAH', 'LECTURE', 'FUNDRAISER', 'MEETING', 'COURSE', 'MARKET', 'SOCIAL'].map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<div style={{ flex: 1 }}><label style={S.label}>Start</label><input style={S.input} type="datetime-local" value={eStart} onChange={e => setEStart(e.target.value)} /></div>
|
||||||
|
<div style={{ flex: 1 }}><label style={S.label}>End</label><input style={S.input} type="datetime-local" value={eEnd} onChange={e => setEEnd(e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<div><label style={S.label}>Location</label><input style={S.input} value={eLoc} onChange={e => setELoc(e.target.value)} placeholder="Venue / address" /></div>
|
||||||
|
<button style={S.btn} onClick={handleCreateEvent}>Create Event</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HUB */}
|
||||||
|
{tab === 'hub' && (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', margin: '-16px' }}>
|
||||||
|
<div style={{ padding: '8px 16px', background: 'rgba(16,185,129,0.1)', borderBottom: '1px solid rgba(255,255,255,0.06)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: '11px', color: '#10B981', fontWeight: 600 }}>Komuniti Hub</span>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>powered by HumHub</span>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open('http://192.168.0.17:3110', '_blank')}
|
||||||
|
style={{ marginLeft: 'auto', padding: '4px 10px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.06)', color: 'var(--text)', fontSize: '11px', cursor: 'pointer' }}
|
||||||
|
>Open in Browser</button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src="http://192.168.0.17:3110"
|
||||||
|
style={{ flex: 1, border: 'none', width: '100%', height: '100%' }}
|
||||||
|
title="Komuniti Hub"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type Tab = 'campaigns' | 'start' | 'giving'
|
||||||
|
type Category = 'All' | 'Education' | 'Health' | 'Water' | 'Food' | 'Mosque' | 'Emergency' | 'Community'
|
||||||
|
interface Campaign {
|
||||||
|
id: number; title: string; description: string; category: Exclude<Category, 'All'>
|
||||||
|
target: number; raised: number; endDate: string; image?: string; daysRemaining: number
|
||||||
|
}
|
||||||
|
interface Donation { date: string; campaign: string; amount: number; status: string }
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
input: { flex: 1, padding: '8px 11px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(255,255,255,0.05)', color: 'var(--text)', fontSize: '12px', fontFamily: 'inherit', outline: 'none', minWidth: 0 },
|
||||||
|
label: { fontSize: '10px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' as const, letterSpacing: '0.7px', display: 'block', marginBottom: '4px' },
|
||||||
|
btn: { padding: '8px 14px', borderRadius: '8px', border: 'none', background: '#10B981', color: '#fff', fontSize: '12px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' as const },
|
||||||
|
card: { background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '12px', padding: '14px 16px' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES: Category[] = ['All', 'Education', 'Health', 'Water', 'Food', 'Mosque', 'Emergency', 'Community']
|
||||||
|
const CAT_COLORS: Record<string, string> = { Education: '#3B82F6', Health: '#10B981', Water: '#06B6D4', Food: '#F59E0B', Mosque: '#8B5CF6', Emergency: '#EF4444', Community: '#EC4899' }
|
||||||
|
const HISTORY_KEY = 'infaq-history'
|
||||||
|
|
||||||
|
const SEED: Campaign[] = [
|
||||||
|
{ id: 1, title: 'Build a School', description: 'Help us build a primary school for 300 children in a rural village. The school will include 6 classrooms, a library, and a playground. Every contribution brings education closer to those who need it most.', category: 'Education', target: 50000, raised: 22500, endDate: '2026-12-31', daysRemaining: 213 },
|
||||||
|
{ id: 2, title: 'Clean Water Well', description: 'Provide clean drinking water to a community of 500 families. This well will eliminate waterborne diseases and give women and children back hours spent collecting water each day.', category: 'Water', target: 15000, raised: 10800, endDate: '2026-09-15', daysRemaining: 106 },
|
||||||
|
{ id: 3, title: 'Emergency Flood Relief', description: 'Immediate relief for families affected by severe flooding. Your donation provides food packs, clean water, blankets, and temporary shelter to those who have lost everything.', category: 'Emergency', target: 30000, raised: 26400, endDate: '2026-07-01', daysRemaining: 30 },
|
||||||
|
{ id: 4, title: 'Mosque Renovation', description: 'Restore and expand our community mosque to accommodate growing congregation. The renovation includes a new prayer hall, wudu facilities, and a learning center for Quran classes.', category: 'Mosque', target: 100000, raised: 34000, endDate: '2027-03-31', daysRemaining: 303 },
|
||||||
|
{ id: 5, title: 'Feed the Orphans', description: 'Provide nutritious meals for 200 orphans for one full year. This program ensures three balanced meals daily, plus school supplies and basic healthcare for every child.', category: 'Food', target: 8000, raised: 7280, endDate: '2026-08-20', daysRemaining: 80 },
|
||||||
|
{ id: 6, title: 'Medical Clinic', description: 'Establish a community medical clinic serving 10,000 people in an underserved area. The clinic will provide general consultations, maternal care, vaccinations, and emergency first aid.', category: 'Health', target: 25000, raised: 14000, endDate: '2026-11-01', daysRemaining: 153 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function ErrBox({ msg }: { msg: string }) {
|
||||||
|
return <div style={{ fontSize: '12px', color: '#F87171', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '8px', padding: '9px 12px' }}>{msg}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InfaqApp() {
|
||||||
|
const { authToken } = useApp()
|
||||||
|
const [tab, setTab] = useState<Tab>('campaigns')
|
||||||
|
const [campaigns, setCampaigns] = useState<Campaign[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [catFilter, setCatFilter] = useState<Category>('All')
|
||||||
|
const [detailCampaign, setDetailCampaign] = useState<Campaign | null>(null)
|
||||||
|
const [showDonate, setShowDonate] = useState(false)
|
||||||
|
const [donateAmount, setDonateAmount] = useState('')
|
||||||
|
const [donorName, setDonorName] = useState('')
|
||||||
|
const [anonymous, setAnonymous] = useState(false)
|
||||||
|
const [donateLoading, setDonateLoading] = useState(false)
|
||||||
|
const [donateError, setDonateError] = useState('')
|
||||||
|
const [donors, setDonors] = useState<{ name: string; amount: number; date: string }[]>([])
|
||||||
|
const [history, setHistory] = useState<Donation[]>([])
|
||||||
|
|
||||||
|
const [startTitle, setStartTitle] = useState('')
|
||||||
|
const [startDesc, setStartDesc] = useState('')
|
||||||
|
const [startCat, setStartCat] = useState<Exclude<Category, 'All'>>('Education')
|
||||||
|
const [startTarget, setStartTarget] = useState('')
|
||||||
|
const [startEnd, setStartEnd] = useState('')
|
||||||
|
const [startImg, setStartImg] = useState('')
|
||||||
|
const [startLoading, setStartLoading] = useState(false)
|
||||||
|
const [startError, setStartError] = useState('')
|
||||||
|
const [startSuccess, setStartSuccess] = useState('')
|
||||||
|
|
||||||
|
const loadHistory = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HISTORY_KEY)
|
||||||
|
setHistory(raw ? JSON.parse(raw) : [])
|
||||||
|
} catch { setHistory([]) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadDonors = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HISTORY_KEY)
|
||||||
|
const all: Donation[] = raw ? JSON.parse(raw) : []
|
||||||
|
setDonors(all.map(d => ({ name: d.status === 'anonymous' ? 'Anonymous' : d.campaign, amount: d.amount, date: d.date })))
|
||||||
|
} catch { setDonors([]) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchCampaigns = useCallback(async () => {
|
||||||
|
setLoading(true); setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.infaq}/api/campaigns`)
|
||||||
|
if (!res.ok) throw new Error('API unavailable')
|
||||||
|
const data = await res.json()
|
||||||
|
setCampaigns(Array.isArray(data) ? data : data.campaigns ?? SEED)
|
||||||
|
} catch {
|
||||||
|
setCampaigns(SEED)
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { fetchCampaigns(); loadHistory() }, [fetchCampaigns, loadHistory])
|
||||||
|
|
||||||
|
useEffect(() => { if (detailCampaign) loadDonors() }, [detailCampaign, loadDonors])
|
||||||
|
|
||||||
|
const filtered = campaigns.filter(c => {
|
||||||
|
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) || c.description.toLowerCase().includes(search.toLowerCase())
|
||||||
|
const matchCat = catFilter === 'All' || c.category === catFilter
|
||||||
|
return matchSearch && matchCat
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDonate = async () => {
|
||||||
|
const amt = Number(donateAmount)
|
||||||
|
if (!amt || amt <= 0) { setDonateError('Enter a valid amount'); return }
|
||||||
|
setDonateLoading(true); setDonateError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.infaq}/api/campaigns/${detailCampaign!.id}/donate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` },
|
||||||
|
body: JSON.stringify({ amount: amt, donorName: anonymous ? 'Anonymous' : donorName || 'Anonymous', anonymous }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Donation failed')
|
||||||
|
const donation: Donation = { date: new Date().toLocaleDateString('en-MY'), campaign: detailCampaign!.title, amount: amt, status: anonymous ? 'anonymous' : 'public' }
|
||||||
|
const updated = [...history, donation]
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(updated))
|
||||||
|
setHistory(updated)
|
||||||
|
setCampaigns(prev => prev.map(c => c.id === detailCampaign!.id ? { ...c, raised: c.raised + amt } : c))
|
||||||
|
setShowDonate(false)
|
||||||
|
setDonateAmount(''); setDonorName(''); setAnonymous(false)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setDonateError(e instanceof Error ? e.message : 'Donation failed')
|
||||||
|
} finally { setDonateLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartCampaign = async () => {
|
||||||
|
if (!startTitle || !startDesc || !startTarget || !startEnd) { setStartError('Fill in all required fields'); return }
|
||||||
|
const targetNum = Number(startTarget)
|
||||||
|
if (!targetNum || targetNum <= 0) { setStartError('Target must be a positive number'); return }
|
||||||
|
setStartLoading(true); setStartError(''); setStartSuccess('')
|
||||||
|
try {
|
||||||
|
const body = { title: startTitle, description: startDesc, category: startCat, target: targetNum, endDate: startEnd, image: startImg || undefined }
|
||||||
|
const res = await fetch(`${API.infaq}/api/campaigns`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Failed to create campaign')
|
||||||
|
const newCamp = await res.json()
|
||||||
|
setCampaigns(prev => [...prev, { ...newCamp, id: newCamp.id ?? Date.now(), raised: 0, daysRemaining: Math.ceil((new Date(startEnd).getTime() - Date.now()) / 86400000) }])
|
||||||
|
setStartSuccess('Campaign created successfully!')
|
||||||
|
setStartTitle(''); setStartDesc(''); setStartTarget(''); setStartEnd(''); setStartImg('')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setStartError(e instanceof Error ? e.message : 'Failed to create campaign')
|
||||||
|
} finally { setStartLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHistory = () => {
|
||||||
|
localStorage.removeItem(HISTORY_KEY)
|
||||||
|
setHistory([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalGiven = history.reduce((sum, d) => sum + d.amount, 0)
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
return (
|
||||||
|
<AppWindow id="infaq" title="Infaq" icon="🤝" width={540} height={580}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '12px', color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: '32px' }}>🔒</span>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text)' }}>Authentication Required</div>
|
||||||
|
<div style={{ fontSize: '12px', maxWidth: '220px', lineHeight: 1.5 }}>Open Ummah ID app and log in first</div>
|
||||||
|
</div>
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="infaq" title="Infaq" icon="🤝" width={540} height={580}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', background: 'rgba(255,255,255,0.04)', borderRadius: '9px', padding: '4px', marginBottom: '14px' }}>
|
||||||
|
{(['campaigns', 'start', 'giving'] as Tab[]).map(t => (
|
||||||
|
<button key={t} onClick={() => setTab(t)} style={{ flex: 1, padding: '7px', borderRadius: '6px', border: 'none', background: tab === t ? '#10B981' : 'transparent', color: tab === t ? '#fff' : 'var(--text-muted)', fontSize: '12px', fontWeight: 600, cursor: 'pointer', textTransform: 'capitalize', fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'campaigns' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<input style={S.input} placeholder="Search campaigns…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
|
||||||
|
{CATEGORIES.map(c => (
|
||||||
|
<button key={c} onClick={() => setCatFilter(c)} style={{ padding: '4px 10px', borderRadius: '99px', border: '1px solid rgba(255,255,255,0.10)', background: catFilter === c ? (c === 'All' ? '#10B981' : CAT_COLORS[c]) : 'rgba(255,255,255,0.05)', color: '#fff', fontSize: '10px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>{c}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{filtered.length} campaign{filtered.length !== 1 ? 's' : ''} found</div>
|
||||||
|
{loading && <div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>Loading…</div>}
|
||||||
|
{error && <ErrBox msg={error} />}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '32px 16px', color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||||
|
<div style={{ fontSize: '28px', marginBottom: '8px' }}>🔍</div>
|
||||||
|
No campaigns found matching your search.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '340px', overflowY: 'auto' }}>
|
||||||
|
{filtered.map(c => {
|
||||||
|
const pct = Math.min(Math.round((c.raised / c.target) * 100), 100)
|
||||||
|
return (
|
||||||
|
<div key={c.id} style={{ ...S.card, cursor: 'pointer' }} onClick={() => setDetailCampaign(c)}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text)' }}>{c.title}</div>
|
||||||
|
<span style={{ padding: '2px 7px', borderRadius: '99px', fontSize: '9px', fontWeight: 600, background: CAT_COLORS[c.category] ?? '#475569', color: '#fff', flexShrink: 0 }}>{c.category}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', lineHeight: 1.5, marginBottom: '10px', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{c.description}</div>
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: 'var(--text-muted)', marginBottom: '3px' }}>
|
||||||
|
<span>RM{(c.raised).toLocaleString()} raised</span>
|
||||||
|
<span>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '100%', height: '5px', borderRadius: '99px', background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', borderRadius: '99px', background: 'linear-gradient(90deg,#10B981,#059669)', transition: 'width 0.3s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Target: RM{c.target.toLocaleString()} · {c.daysRemaining} days left</span>
|
||||||
|
<button onClick={e => { e.stopPropagation(); setDetailCampaign(c); setShowDonate(true) }} style={{ ...S.btn, padding: '5px 12px', fontSize: '11px' }}>Donate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'start' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{startSuccess && <div style={{ fontSize: '12px', color: '#10B981', background: 'rgba(16,185,129,0.08)', borderRadius: '8px', padding: '9px 12px' }}>{startSuccess}</div>}
|
||||||
|
{startError && <ErrBox msg={startError} />}
|
||||||
|
<div>
|
||||||
|
<div style={S.label}>Campaign Title *</div>
|
||||||
|
<input style={S.input} placeholder="e.g. Build a School" value={startTitle} onChange={e => setStartTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={S.label}>Description *</div>
|
||||||
|
<textarea style={{ ...S.input, resize: 'vertical', minHeight: '72px', fontFamily: 'inherit' }} placeholder="Describe your campaign…" value={startDesc} onChange={e => setStartDesc(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={S.label}>Category *</div>
|
||||||
|
<select style={{ ...S.input, flex: 1 }} value={startCat} onChange={e => setStartCat(e.target.value as Exclude<Category, 'All'>)}>
|
||||||
|
{CATEGORIES.filter(c => c !== 'All').map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={S.label}>Target Amount (RM) *</div>
|
||||||
|
<input style={{ ...S.input, flex: 1 }} type="number" placeholder="50000" value={startTarget} onChange={e => setStartTarget(e.target.value)} min="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={S.label}>End Date *</div>
|
||||||
|
<input style={{ ...S.input, flex: 1 }} type="date" value={startEnd} onChange={e => setStartEnd(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={S.label}>Image URL (optional)</div>
|
||||||
|
<input style={{ ...S.input, flex: 1 }} placeholder="https://…" value={startImg} onChange={e => setStartImg(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button style={{ ...S.btn, opacity: startLoading ? 0.6 : 1 }} disabled={startLoading} onClick={handleStartCampaign}>{startLoading ? 'Creating…' : 'Launch Campaign'}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'giving' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div style={{ ...S.card, background: 'linear-gradient(135deg,rgba(16,185,129,0.12),rgba(5,150,105,0.06))', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={S.label}>Total Given</div>
|
||||||
|
<div style={{ fontSize: '22px', fontWeight: 700, color: '#10B981' }}>RM{totalGiven.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<button onClick={clearHistory} style={{ padding: '6px 12px', borderRadius: '8px', border: '1px solid rgba(239,68,68,0.3)', background: 'rgba(239,68,68,0.1)', color: '#F87171', fontSize: '11px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>Clear History</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '32px 16px', color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||||
|
<div style={{ fontSize: '28px', marginBottom: '8px' }}>🤲</div>
|
||||||
|
No donations yet. Start your first infaq today!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '11px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ color: 'var(--text-muted)', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px 6px', fontWeight: 600 }}>Date</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '8px 6px', fontWeight: 600 }}>Campaign</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '8px 6px', fontWeight: 600 }}>Amount</th>
|
||||||
|
<th style={{ textAlign: 'center', padding: '8px 6px', fontWeight: 600 }}>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{history.map((d, i) => (
|
||||||
|
<tr key={i} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||||
|
<td style={{ padding: '8px 6px', color: 'var(--text-muted)' }}>{d.date}</td>
|
||||||
|
<td style={{ padding: '8px 6px', color: 'var(--text)', fontWeight: 500 }}>{d.campaign}</td>
|
||||||
|
<td style={{ padding: '8px 6px', textAlign: 'right', color: '#10B981', fontWeight: 600 }}>RM{d.amount.toLocaleString()}</td>
|
||||||
|
<td style={{ padding: '8px 6px', textAlign: 'center' }}>
|
||||||
|
<span style={{ padding: '2px 7px', borderRadius: '99px', fontSize: '9px', fontWeight: 600, background: d.status === 'anonymous' ? 'rgba(239,68,68,0.1)' : 'rgba(16,185,129,0.1)', color: d.status === 'anonymous' ? '#F87171' : '#10B981' }}>{d.status}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailCampaign && !showDonate && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }} onClick={() => setDetailCampaign(null)}>
|
||||||
|
<div style={{ background: '#0C1A2E', border: '1px solid rgba(255,255,255,0.12)', borderRadius: '16px', width: '420px', maxHeight: '80vh', display: 'flex', flexDirection: 'column', boxShadow: '0 24px 64px rgba(0,0,0,0.6)' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text)' }}>{detailCampaign.title}</span>
|
||||||
|
<button onClick={() => setDetailCampaign(null)} style={{ width: '20px', height: '20px', borderRadius: '50%', background: 'rgba(255,80,80,0.7)', border: 'none', cursor: 'pointer', fontSize: '11px', color: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '14px', overflowY: 'auto' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||||
|
<span style={{ padding: '3px 8px', borderRadius: '99px', fontSize: '10px', fontWeight: 600, background: CAT_COLORS[detailCampaign.category] ?? '#475569', color: '#fff' }}>{detailCampaign.category}</span>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{detailCampaign.daysRemaining} days remaining</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', lineHeight: 1.7 }}>{detailCampaign.description}</div>
|
||||||
|
<div style={{ ...S.card }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '11px', color: 'var(--text-muted)', marginBottom: '4px' }}>
|
||||||
|
<span>RM{detailCampaign.raised.toLocaleString()} raised of RM{detailCampaign.target.toLocaleString()}</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#10B981' }}>{Math.round((detailCampaign.raised / detailCampaign.target) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '100%', height: '6px', borderRadius: '99px', background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${Math.min(Math.round((detailCampaign.raised / detailCampaign.target) * 100), 100)}%`, height: '100%', borderRadius: '99px', background: 'linear-gradient(90deg,#10B981,#059669)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{donors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ ...S.label, marginBottom: '6px' }}>Recent Donors</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', maxHeight: '120px', overflowY: 'auto' }}>
|
||||||
|
{donors.slice(-5).reverse().map((d, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 8px', borderRadius: '6px', background: 'rgba(255,255,255,0.03)', fontSize: '11px' }}>
|
||||||
|
<span style={{ color: 'var(--text)' }}>{d.name}</span>
|
||||||
|
<span style={{ color: '#10B981', fontWeight: 600 }}>RM{d.amount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setShowDonate(true)} style={{ ...S.btn, width: '100%', padding: '10px' }}>Donate Now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailCampaign && showDonate && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }} onClick={() => { setShowDonate(false); setDonateError('') }}>
|
||||||
|
<div style={{ background: '#0C1A2E', border: '1px solid rgba(255,255,255,0.12)', borderRadius: '16px', width: '380px', boxShadow: '0 24px 64px rgba(0,0,0,0.6)' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text)' }}>Donate to {detailCampaign.title}</span>
|
||||||
|
<button onClick={() => { setShowDonate(false); setDonateError('') }} style={{ width: '20px', height: '20px', borderRadius: '50%', background: 'rgba(255,80,80,0.7)', border: 'none', cursor: 'pointer', fontSize: '11px', color: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{donateError && <ErrBox msg={donateError} />}
|
||||||
|
<div>
|
||||||
|
<div style={S.label}>Amount (RM) *</div>
|
||||||
|
<input style={S.input} type="number" placeholder="50" value={donateAmount} onChange={e => setDonateAmount(e.target.value)} min="0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={S.label}>Your Name</div>
|
||||||
|
<input style={S.input} placeholder="Enter your name" value={donorName} onChange={e => setDonorName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: 'var(--text-muted)', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={anonymous} onChange={e => setAnonymous(e.target.checked)} style={{ accentColor: '#10B981' }} />
|
||||||
|
Donate anonymously
|
||||||
|
</label>
|
||||||
|
<button style={{ ...S.btn, width: '100%', padding: '10px', opacity: donateLoading ? 0.6 : 1 }} disabled={donateLoading} onClick={handleDonate}>{donateLoading ? 'Processing…' : 'Confirm Donation'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API, apiFetch } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type Tab = 'network' | 'accounts' | 'simulate'
|
||||||
|
|
||||||
|
interface NetworkInfo { name: string; chainId: number; status: string; blockHeight: number; validators: number; tps: number; chaosMode: boolean }
|
||||||
|
interface TestAccount { id: string; role: string; name: string; address: string; balance: number; currency: string }
|
||||||
|
interface Simulation { id: string; type: string; status: string; startedAt: string }
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<string, string> = { REGULATOR: '#8B5CF6', MERCHANT: '#F59E0B', CONSUMER: '#3B82F6' }
|
||||||
|
const SIM_TYPES = ['TRANSACTION', 'TRANSFER', 'CONTRACT_DEPLOY', 'TRANSACTION_FLOOD']
|
||||||
|
|
||||||
|
export default function MockNetApp() {
|
||||||
|
const { authToken } = useApp()
|
||||||
|
const [tab, setTab] = useState<Tab>('network')
|
||||||
|
const [network, setNetwork] = useState<NetworkInfo | null>(null)
|
||||||
|
const [accounts, setAccounts] = useState<TestAccount[]>([])
|
||||||
|
const [simulations, setSimulations] = useState<Simulation[]>([])
|
||||||
|
const [simType, setSimType] = useState('TRANSACTION')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
|
||||||
|
const fetchNetwork = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<NetworkInfo>(`${API.mocknet}/network`)
|
||||||
|
setNetwork(data)
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchAccounts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<TestAccount[]>(`${API.mocknet}/test-accounts`)
|
||||||
|
setAccounts(data)
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchSimulations = useCallback(async () => {
|
||||||
|
if (!authToken) return
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<Simulation[]>(`${API.mocknet}/simulations`, authToken)
|
||||||
|
setSimulations(data)
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}, [authToken])
|
||||||
|
|
||||||
|
useEffect(() => { fetchNetwork(); fetchAccounts() }, [fetchNetwork, fetchAccounts])
|
||||||
|
useEffect(() => { if (tab === 'network') { const t = setInterval(fetchNetwork, 5000); return () => clearInterval(t) } }, [tab, fetchNetwork])
|
||||||
|
useEffect(() => { if (tab === 'simulate' && authToken) fetchSimulations() }, [tab, authToken, fetchSimulations])
|
||||||
|
|
||||||
|
const runSimulation = async () => {
|
||||||
|
if (!authToken) return
|
||||||
|
setLoading(true); setError(''); setSuccess('')
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ success: boolean; simulationId: string; status: string }>(
|
||||||
|
`${API.mocknet}/simulations/create`, authToken, { method: 'POST', body: JSON.stringify({ type: simType, params: {} }) }
|
||||||
|
)
|
||||||
|
setSuccess(`Simulation started: ${res.simulationId.slice(0, 12)}...`)
|
||||||
|
await fetchSimulations()
|
||||||
|
} catch (e) { setError((e as Error).message) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = { background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 8, color: 'var(--text)', padding: '8px 12px', width: '100%', fontSize: 13 }
|
||||||
|
const btnStyle: React.CSSProperties = { background: 'var(--brand)', color: '#fff', border: 'none', borderRadius: 8, padding: '9px 18px', cursor: 'pointer', fontSize: 13, fontWeight: 600 }
|
||||||
|
const tabStyle = (t: Tab): React.CSSProperties => ({ padding: '6px 14px', borderRadius: 7, cursor: 'pointer', fontSize: 12, fontWeight: 600, background: tab === t ? 'var(--brand)' : 'rgba(255,255,255,0.06)', color: tab === t ? '#fff' : 'var(--text-muted)', border: 'none' })
|
||||||
|
const labelStyle: React.CSSProperties = { fontSize: 11, color: 'var(--text-muted)', marginBottom: 4, display: 'block' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="mocknet" title="Mock-Net" icon="🧪" width={460} height={500}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 16 }}>
|
||||||
|
{(['network', 'accounts', 'simulate'] as Tab[]).map(t => <button key={t} style={tabStyle(t)} onClick={() => setTab(t)}>{t.charAt(0).toUpperCase() + t.slice(1)}</button>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'network' && (
|
||||||
|
<div>
|
||||||
|
{!network ? <p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading network...</p> : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 20 }}>🌐</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)' }}>{network.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>Chain ID: {network.chainId}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ marginLeft: 'auto', background: 'rgba(16,185,129,0.15)', color: '#10B981', fontSize: 11, padding: '3px 10px', borderRadius: 99, fontWeight: 700 }}>{network.status}</span>
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
['Block Height', network.blockHeight.toLocaleString()],
|
||||||
|
['Validators', network.validators],
|
||||||
|
['TPS Capacity', network.tps.toLocaleString()],
|
||||||
|
['Chaos Mode', network.chaosMode ? '⚡ Active' : 'Off'],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={String(k)} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.05)', fontSize: 13 }}>
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>{k}</span>
|
||||||
|
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{String(v)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text-dim)', marginTop: 12 }}>Block height refreshes every 5 seconds</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'accounts' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{accounts.length === 0 ? <p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading accounts...</p> : accounts.map(acc => (
|
||||||
|
<div key={acc.id} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text)' }}>{acc.name}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: `${ROLE_COLORS[acc.role] || '#6B7280'}22`, color: ROLE_COLORS[acc.role] || '#6B7280' }}>{acc.role}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontFamily: 'monospace', marginBottom: 6 }}>{acc.address.slice(0, 10)}...{acc.address.slice(-6)}</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#10B981' }}>{acc.balance.toLocaleString()} <span style={{ fontSize: 12, fontWeight: 400 }}>{acc.currency}</span></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'simulate' && (
|
||||||
|
<div>
|
||||||
|
{!authToken ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 12 }}>🔒</div>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Open Ummah ID app and log in first</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>Simulation Type</label>
|
||||||
|
<select value={simType} onChange={e => setSimType(e.target.value)} style={{ ...inputStyle, appearance: 'none' }}>
|
||||||
|
{SIM_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onClick={runSimulation} disabled={loading} style={{ ...btnStyle, width: '100%', marginBottom: 16 }}>
|
||||||
|
{loading ? 'Running...' : '▶ Run Simulation'}
|
||||||
|
</button>
|
||||||
|
{error && <div style={{ color: '#F87171', fontSize: 12, marginBottom: 12 }}>{error}</div>}
|
||||||
|
{success && <div style={{ color: '#10B981', fontSize: 12, marginBottom: 12 }}>✓ {success}</div>}
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>Past Simulations ({simulations.length})</div>
|
||||||
|
{simulations.slice(-4).reverse().map(sim => (
|
||||||
|
<div key={sim.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)', fontSize: 12 }}>
|
||||||
|
<span style={{ color: 'var(--text)' }}>{sim.type}</span>
|
||||||
|
<span style={{ color: '#10B981' }}>{sim.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{simulations.length === 0 && <p style={{ fontSize: 12, color: 'var(--text-dim)' }}>No simulations yet</p>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API, apiFetch } from '../hooks/useApi'
|
||||||
|
|
||||||
|
type Tab = 'today' | 'calendar'
|
||||||
|
|
||||||
|
interface PrayerTimes { fajr: string; sunrise: string; dhuhr: string; asr: string; maghrib: string; isha: string }
|
||||||
|
interface HijriDate { year: number; month: number; day: number; monthName: string }
|
||||||
|
interface PrayerResponse { date: string; hijri: HijriDate; location: { lat: number; lon: number }; method: string; times: PrayerTimes }
|
||||||
|
interface NextPrayer { prayer: string; time: string; remainingMinutes: number }
|
||||||
|
interface CalendarDay { date: string; hijri: HijriDate; times: PrayerTimes }
|
||||||
|
|
||||||
|
const PRAYER_NAMES: Record<string, string> = { fajr: 'Fajr', sunrise: 'Sunrise', dhuhr: 'Dhuhr', asr: 'Asr', maghrib: 'Maghrib', isha: 'Isha' }
|
||||||
|
const METHODS = ['MuslimWorldLeague', 'NorthAmerica', 'Egyptian', 'Karachi', 'UmmAlQura', 'Singapore', 'Kuwait', 'Qatar']
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = { background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 8, color: 'var(--text)', padding: '7px 10px', fontSize: 12, width: '100%', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }
|
||||||
|
const labelStyle: React.CSSProperties = { fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', display: 'block', marginBottom: 3 }
|
||||||
|
const tabStyle = (active: boolean): React.CSSProperties => ({ padding: '5px 14px', borderRadius: 7, cursor: 'pointer', fontSize: 12, fontWeight: 600, background: active ? 'var(--brand)' : 'rgba(255,255,255,0.06)', color: active ? '#fff' : 'var(--text-muted)', border: 'none' })
|
||||||
|
|
||||||
|
function fmtTime(t: string) { return t || '--:--' }
|
||||||
|
function fmtDate(d: string) { return new Date(d).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) }
|
||||||
|
|
||||||
|
export default function PrayerApp() {
|
||||||
|
const [tab, setTab] = useState<Tab>('today')
|
||||||
|
const [lat, setLat] = useState(3.139)
|
||||||
|
const [lon, setLon] = useState(101.6869)
|
||||||
|
const [method, setMethod] = useState('MuslimWorldLeague')
|
||||||
|
const [data, setData] = useState<PrayerResponse | null>(null)
|
||||||
|
const [next, setNext] = useState<NextPrayer | null>(null)
|
||||||
|
const [qibla, setQibla] = useState<{ bearing: number; direction: string } | null>(null)
|
||||||
|
const [cal, setCal] = useState<CalendarDay[]>([])
|
||||||
|
const [calMonth, setCalMonth] = useState(new Date().getMonth() + 1)
|
||||||
|
const [calYear, setCalYear] = useState(new Date().getFullYear())
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const fetchToday = useCallback(async () => {
|
||||||
|
setLoading(true); setError('')
|
||||||
|
try {
|
||||||
|
const [times, nextP, qib] = await Promise.all([
|
||||||
|
apiFetch<PrayerResponse>(`${API.prayer}/api/prayer/times?lat=${lat}&lon=${lon}&method=${method}`),
|
||||||
|
apiFetch<NextPrayer>(`${API.prayer}/api/prayer/next?lat=${lat}&lon=${lon}`),
|
||||||
|
apiFetch<{ bearing: number; direction: string }>(`${API.prayer}/api/prayer/qibla?lat=${lat}&lon=${lon}`),
|
||||||
|
])
|
||||||
|
setData(times); setNext(nextP); setQibla(qib)
|
||||||
|
} catch (e) { setError((e as Error).message) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [lat, lon, method])
|
||||||
|
|
||||||
|
const fetchCalendar = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<CalendarDay[]>(`${API.prayer}/api/prayer/calendar?month=${calMonth}&year=${calYear}&lat=${lat}&lon=${lon}&method=${method}`)
|
||||||
|
setCal(res)
|
||||||
|
} catch { /* silent */ }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [lat, lon, method, calMonth, calYear])
|
||||||
|
|
||||||
|
const geolocate = () => {
|
||||||
|
if (!navigator.geolocation) return
|
||||||
|
navigator.geolocation.getCurrentPosition(p => { setLat(+p.coords.latitude.toFixed(4)); setLon(+p.coords.longitude.toFixed(4)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchToday() }, [fetchToday])
|
||||||
|
useEffect(() => { if (tab === 'calendar') fetchCalendar() }, [tab, fetchCalendar])
|
||||||
|
|
||||||
|
const todayStr = new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="prayer" title="Prayer Times" icon="🌙" width={400} height={500}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 14 }}>
|
||||||
|
<button style={tabStyle(tab === 'today')} onClick={() => setTab('today')}>Today</button>
|
||||||
|
<button style={tabStyle(tab === 'calendar')} onClick={() => setTab('calendar')}>Calendar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location bar */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'flex-end' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>Latitude</label>
|
||||||
|
<input style={inputStyle} type="number" step="0.0001" value={lat} onChange={e => setLat(+e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>Longitude</label>
|
||||||
|
<input style={inputStyle} type="number" step="0.0001" value={lon} onChange={e => setLon(+e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button onClick={geolocate} style={{ padding: '7px 10px', background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 8, color: 'var(--text)', cursor: 'pointer', fontSize: 14, flexShrink: 0 }}>📍</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={labelStyle}>Calculation Method</label>
|
||||||
|
<select value={method} onChange={e => setMethod(e.target.value)} style={{ ...inputStyle, appearance: 'none' }}>
|
||||||
|
{METHODS.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ color: '#F87171', fontSize: 12, marginBottom: 10 }}>{error}</div>}
|
||||||
|
{loading && <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>Loading...</div>}
|
||||||
|
|
||||||
|
{tab === 'today' && data && (
|
||||||
|
<>
|
||||||
|
{/* Hijri date */}
|
||||||
|
<div style={{ textAlign: 'center', background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 0', marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)' }}>{data.hijri.day} {data.hijri.monthName} {data.hijri.year} AH</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{fmtDate(data.date)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prayer times */}
|
||||||
|
{(Object.entries(data.times) as [string, string][]).map(([key, time]) => {
|
||||||
|
const isNext = next?.prayer.toLowerCase() === key
|
||||||
|
return (
|
||||||
|
<div key={key} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '9px 12px', marginBottom: 4, borderRadius: 8, background: isNext ? 'rgba(16,185,129,0.12)' : 'rgba(255,255,255,0.03)', border: isNext ? '1px solid rgba(16,185,129,0.3)' : '1px solid transparent' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: isNext ? 700 : 500, color: isNext ? '#10B981' : 'var(--text)' }}>{PRAYER_NAMES[key]}</span>
|
||||||
|
{isNext && <span style={{ fontSize: 10, background: '#10B981', color: '#fff', borderRadius: 99, padding: '1px 7px', fontWeight: 700 }}>NEXT · {next.remainingMinutes}m</span>}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: isNext ? '#10B981' : 'var(--text)' }}>{fmtTime(time)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Qibla */}
|
||||||
|
{qibla && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, marginTop: 12, padding: '8px 0', borderTop: '1px solid rgba(255,255,255,0.06)', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||||
|
<span style={{ fontSize: 18 }}>🧭</span>
|
||||||
|
<span>Qibla: <strong style={{ color: 'var(--text)' }}>{qibla.bearing.toFixed(1)}° {qibla.direction}</strong></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'calendar' && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>Month</label>
|
||||||
|
<select value={calMonth} onChange={e => setCalMonth(+e.target.value)} style={{ ...inputStyle, appearance: 'none' }}>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => <option key={i+1} value={i+1}>{new Date(2000, i).toLocaleString('default', { month: 'long' })}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>Year</label>
|
||||||
|
<input style={inputStyle} type="number" value={calYear} onChange={e => setCalYear(+e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ color: 'var(--text-muted)', textAlign: 'left' }}>
|
||||||
|
{['Date', 'Hijri', 'Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'].map(h => <th key={h} style={{ padding: '4px 6px', borderBottom: '1px solid rgba(255,255,255,0.08)', fontWeight: 600 }}>{h}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cal.map(row => {
|
||||||
|
const isToday = row.date === todayStr
|
||||||
|
return (
|
||||||
|
<tr key={row.date} style={{ background: isToday ? 'rgba(16,185,129,0.08)' : 'transparent' }}>
|
||||||
|
<td style={{ padding: '3px 6px', color: isToday ? '#10B981' : 'var(--text)', fontWeight: isToday ? 700 : 400 }}>{row.date.slice(8)}</td>
|
||||||
|
<td style={{ padding: '3px 6px', color: 'var(--text-muted)' }}>{row.hijri.day}/{row.hijri.month}</td>
|
||||||
|
{(['fajr', 'dhuhr', 'asr', 'maghrib', 'isha'] as (keyof PrayerTimes)[]).map(k => <td key={k} style={{ padding: '3px 6px', color: 'var(--text)' }}>{row.times[k]}</td>)}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{cal.length === 0 && !loading && <tr><td colSpan={7} style={{ padding: '20px 6px', color: 'var(--text-muted)', textAlign: 'center' }}>Select month and year above</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type Tab = 'book' | 'bookings' | 'about'
|
||||||
|
|
||||||
|
interface Animal {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
price: number
|
||||||
|
maxShares: number
|
||||||
|
pricePerShare: number | null
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: string
|
||||||
|
animal: string
|
||||||
|
shareCount: number
|
||||||
|
country: string
|
||||||
|
type: string
|
||||||
|
status: 'Confirmed' | 'Processing' | 'Delivered'
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMALS: Animal[] = [
|
||||||
|
{ id: 'goat', name: 'Goat', price: 450, maxShares: 1, pricePerShare: null, icon: '🐐' },
|
||||||
|
{ id: 'sheep', name: 'Sheep', price: 550, maxShares: 1, pricePerShare: null, icon: '🐑' },
|
||||||
|
{ id: 'cow', name: 'Cow', price: 3500, maxShares: 7, pricePerShare: 500, icon: '🐄' },
|
||||||
|
{ id: 'camel', name: 'Camel', price: 5500, maxShares: 7, pricePerShare: 786, icon: '🐪' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const COUNTRIES = ['Malaysia', 'Indonesia', 'Palestine', 'Yemen', 'Somalia']
|
||||||
|
const TYPES = ['Qurban', 'Aqeeqa', 'Nadir']
|
||||||
|
const STATUS_COLORS: Record<string, string> = { Confirmed: '#10B981', Processing: '#F59E0B', Delivered: '#0284C7' }
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
input: { flex: 1, padding: '8px 11px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(255,255,255,0.05)', color: 'var(--text)', fontSize: '12px', fontFamily: 'inherit', outline: 'none', minWidth: 0 },
|
||||||
|
label: { fontSize: '10px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' as const, letterSpacing: '0.7px', display: 'block', marginBottom: '4px' },
|
||||||
|
btn: { padding: '8px 14px', borderRadius: '8px', border: 'none', background: '#10B981', color: '#fff', fontSize: '12px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' as const },
|
||||||
|
card: { background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '12px', padding: '14px 16px' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrBox({ msg }: { msg: string }) {
|
||||||
|
return <div style={{ fontSize: '12px', color: '#F87171', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '8px', padding: '9px 12px' }}>{msg}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcEidCountdown() {
|
||||||
|
const eid = new Date(2026, 5, 27)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = eid.getTime() - now.getTime()
|
||||||
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||||
|
return { days, label: 'Eid al-Adha 1448' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QurbanApp() {
|
||||||
|
const { authToken } = useApp()
|
||||||
|
const [tab, setTab] = useState<Tab>('book')
|
||||||
|
const [selectedAnimal, setSelectedAnimal] = useState<string | null>(null)
|
||||||
|
const [shareCount, setShareCount] = useState(1)
|
||||||
|
const [country, setCountry] = useState('Malaysia')
|
||||||
|
const [sacrificeType, setSacrificeType] = useState('Qurban')
|
||||||
|
const [wakalaConsent, setWakalaConsent] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([])
|
||||||
|
|
||||||
|
const animal = ANIMALS.find(a => a.id === selectedAnimal)
|
||||||
|
const totalPrice = animal ? (animal.pricePerShare ? animal.pricePerShare * shareCount : animal.price) : 0
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('qurban-bookings')
|
||||||
|
if (stored) {
|
||||||
|
try { setBookings(JSON.parse(stored)) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBook = useCallback(async () => {
|
||||||
|
if (!selectedAnimal || !wakalaConsent || !animal || !authToken) return
|
||||||
|
setLoading(true); setError(''); setSuccess('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.qurban}/api/qurban/book`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` },
|
||||||
|
body: JSON.stringify({ animal: selectedAnimal, shareCount, country, sacrificeType, price: totalPrice }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || res.statusText)
|
||||||
|
const booking: Booking = {
|
||||||
|
id: data.id ?? Date.now().toString(36),
|
||||||
|
animal: animal.name,
|
||||||
|
shareCount,
|
||||||
|
country,
|
||||||
|
type: sacrificeType,
|
||||||
|
status: 'Processing',
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
}
|
||||||
|
const updated = [booking, ...bookings]
|
||||||
|
setBookings(updated)
|
||||||
|
localStorage.setItem('qurban-bookings', JSON.stringify(updated))
|
||||||
|
setSuccess('Booking submitted! May Allah accept your sacrifice.')
|
||||||
|
setSelectedAnimal(null)
|
||||||
|
setShareCount(1)
|
||||||
|
setCountry('Malaysia')
|
||||||
|
setSacrificeType('Qurban')
|
||||||
|
setWakalaConsent(false)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Booking failed')
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [selectedAnimal, wakalaConsent, animal, authToken, shareCount, country, sacrificeType, totalPrice, bookings])
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
return (
|
||||||
|
<AppWindow id="qurban" title="Qurban" icon="🐑" width={480} height={300}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '12px', color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: '32px' }}>🔒</span>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text)' }}>Authentication Required</div>
|
||||||
|
<div style={{ fontSize: '12px', maxWidth: '220px', lineHeight: 1.5 }}>Open Ummah ID app and log in first</div>
|
||||||
|
</div>
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="qurban" title="Qurban" icon="🐑" width={520} height={560}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', background: 'rgba(255,255,255,0.04)', borderRadius: '9px', padding: '4px', marginBottom: '14px' }}>
|
||||||
|
{(['book', 'bookings', 'about'] as Tab[]).map(t => (
|
||||||
|
<button key={t} onClick={() => setTab(t)} style={{ flex: 1, padding: '7px', borderRadius: '6px', border: 'none', background: tab === t ? '#10B981' : 'transparent', color: tab === t ? '#fff' : 'var(--text-muted)', fontSize: '12px', fontWeight: 600, cursor: 'pointer', textTransform: 'capitalize', fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'book' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
{(() => {
|
||||||
|
const { days, label } = calcEidCountdown()
|
||||||
|
return (
|
||||||
|
<div style={{ ...S.card, textAlign: 'center', background: 'linear-gradient(135deg,rgba(16,185,129,0.12),rgba(5,150,105,0.06))' }}>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 700, color: '#10B981' }}>{label}</div>
|
||||||
|
<div style={{ fontSize: '28px', fontWeight: 700, color: 'var(--text)', margin: '4px 0' }}>{days > 0 ? `${days} days` : 'Today!'}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{days > 0 ? 'Remaining until Qurban' : 'Eid Mubarak!'}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||||
|
{ANIMALS.map(a => {
|
||||||
|
const isSelected = selectedAnimal === a.id
|
||||||
|
const shareInfo = a.pricePerShare ? `${a.maxShares} shares · RM ${a.pricePerShare}/share` : '1 share'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => { setSelectedAnimal(a.id); if (a.maxShares === 1) setShareCount(1) }}
|
||||||
|
style={{
|
||||||
|
...S.card,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: isSelected ? '2px solid #10B981' : '1px solid rgba(255,255,255,0.08)',
|
||||||
|
background: isSelected ? 'rgba(16,185,129,0.08)' : 'rgba(255,255,255,0.04)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: '20px' }}>{a.icon}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text)' }}>{a.name}</div>
|
||||||
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>{shareInfo}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '15px', fontWeight: 700, color: '#10B981' }}>RM {a.price.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{animal && animal.maxShares > 1 && (
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.label}>Number of Shares</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginTop: '6px' }}>
|
||||||
|
<button onClick={() => setShareCount(Math.max(1, shareCount - 1))} style={{ ...S.btn, background: 'rgba(255,255,255,0.08)', padding: '4px 12px', fontSize: '14px' }}>−</button>
|
||||||
|
<span style={{ fontSize: '16px', fontWeight: 700, color: 'var(--text)', minWidth: '20px', textAlign: 'center' }}>{shareCount}</span>
|
||||||
|
<button onClick={() => setShareCount(Math.min(animal.maxShares, shareCount + 1))} style={{ ...S.btn, background: 'rgba(255,255,255,0.08)', padding: '4px 12px', fontSize: '14px' }}>+</button>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)', marginLeft: 'auto' }}>RM {(animal.pricePerShare ?? 0) * shareCount} total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={S.label}>Country</div>
|
||||||
|
<select value={country} onChange={e => setCountry(e.target.value)} style={{ ...S.input, width: '100%' }}>
|
||||||
|
{COUNTRIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={S.label}>Sacrifice Type</div>
|
||||||
|
<select value={sacrificeType} onChange={e => setSacrificeType(e.target.value)} style={{ ...S.input, width: '100%' }}>
|
||||||
|
{TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={wakalaConsent} onChange={e => setWakalaConsent(e.target.checked)} style={{ accentColor: '#10B981' }} />
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)', lineHeight: 1.4 }}>I appoint Falah as my wakil (agent) to perform this sacrifice on my behalf in accordance with Shariah.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <ErrBox msg={error} />}
|
||||||
|
{success && <div style={{ fontSize: '11px', color: '#10B981', background: 'rgba(16,185,129,0.08)', borderRadius: '6px', padding: '7px 10px' }}>{success}</div>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleBook}
|
||||||
|
disabled={loading || !selectedAnimal || !wakalaConsent}
|
||||||
|
style={{
|
||||||
|
...S.btn,
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px',
|
||||||
|
fontSize: '13px',
|
||||||
|
opacity: loading || !selectedAnimal || !wakalaConsent ? 0.5 : 1,
|
||||||
|
background: loading ? 'rgba(16,185,129,0.5)' : '#10B981',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Submitting…' : `Book Qurban — RM ${totalPrice.toLocaleString()}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'bookings' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{bookings.length === 0 && (
|
||||||
|
<div style={{ ...S.card, textAlign: 'center', color: 'var(--text-muted)', fontSize: '12px', padding: '24px' }}>
|
||||||
|
No bookings yet. Go to the Book tab to reserve your Qurban.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bookings.map(b => (
|
||||||
|
<div key={b.id} style={{ ...S.card, display: 'flex', alignItems: 'center', gap: '10px', padding: '12px 14px' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text)' }}>
|
||||||
|
{b.animal} · {b.shareCount} {b.shareCount > 1 ? 'shares' : 'share'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||||
|
{b.country} · {b.type} · {b.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: '99px', fontSize: '10px', fontWeight: 600, background: STATUS_COLORS[b.status] ?? '#475569', color: '#fff', flexShrink: 0 }}>{b.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'about' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.label}>Significance</div>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text-muted)', lineHeight: 1.7, margin: '6px 0 0' }}>
|
||||||
|
Qurban (Udhiyah) is the Islamic practice of sacrificing an animal during Eid al-Adha, commemorating Prophet Ibrahim's (AS) willingness to sacrifice his son in obedience to Allah. It is a sunnah mu'akkadah (confirmed practice) for those who can afford it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.label}>Etiquette & Guidelines</div>
|
||||||
|
<ul style={{ fontSize: '12px', color: 'var(--text-muted)', lineHeight: 1.8, margin: '6px 0 0', paddingLeft: '18px' }}>
|
||||||
|
<li>Animal must be healthy and of appropriate age</li>
|
||||||
|
<li>Sacrifice should be performed after Eid prayer</li>
|
||||||
|
<li>One-third may be consumed by the family, one-third given to neighbors, and one-third to the poor</li>
|
||||||
|
<li>Do not sharpen the knife in front of the animal</li>
|
||||||
|
<li>Face the animal towards the Qibla when slaughtering</li>
|
||||||
|
<li>Say "Bismillah, Allahu Akbar" before slaughtering</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.label}>References</div>
|
||||||
|
<ul style={{ fontSize: '12px', color: 'var(--text-muted)', lineHeight: 1.8, margin: '6px 0 0', paddingLeft: '18px' }}>
|
||||||
|
<li>Quran: Surah Al-Kawthar (108:1-3), Surah Al-An'am (6:162)</li>
|
||||||
|
<li>Hadith: Sahih Bukhari & Sahih Muslim — The Prophet (PBUH) sacrificed two rams</li>
|
||||||
|
<li>Scholarly consensus: Qurban is wajib for those who have the means (Hanafi) or sunnah mu'akkadah (Shafi'i, Maliki, Hanbali)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API, apiFetch } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type Tab = 'rules' | 'templates' | 'contracts'
|
||||||
|
|
||||||
|
interface Template { id: string; name: string; description: string; requiredParties: string[]; shariahCompliant: boolean }
|
||||||
|
interface Contract { id: string; templateId: string; amount: number; status: string; createdAt: string }
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
tab: (active: boolean): React.CSSProperties => ({
|
||||||
|
flex: 1, padding: '7px 0', fontSize: 12, fontWeight: active ? 600 : 400,
|
||||||
|
color: active ? '#10B981' : 'var(--text-muted)',
|
||||||
|
background: active ? 'rgba(16,185,129,0.12)' : 'transparent',
|
||||||
|
borderRadius: 6, cursor: 'pointer', border: active ? '1px solid rgba(16,185,129,0.25)' : '1px solid transparent',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}),
|
||||||
|
input: { width: '100%', padding: '8px 10px', borderRadius: 8, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text)', fontSize: 13 } as React.CSSProperties,
|
||||||
|
btn: { padding: '8px 16px', borderRadius: 8, background: '#10B981', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer', border: 'none' } as React.CSSProperties,
|
||||||
|
pill: { display: 'inline-block', padding: '3px 10px', borderRadius: 99, background: 'rgba(16,185,129,0.18)', color: '#34D399', fontSize: 11, fontWeight: 600, border: '1px solid rgba(16,185,129,0.3)' } as React.CSSProperties,
|
||||||
|
card: { background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, padding: '12px 14px', marginBottom: 8 } as React.CSSProperties,
|
||||||
|
err: { color: '#EF4444', fontSize: 12, marginTop: 8 } as React.CSSProperties,
|
||||||
|
muted: { color: 'var(--text-muted)', fontSize: 12 } as React.CSSProperties,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RamzApp() {
|
||||||
|
const { authToken, authUser } = useApp()
|
||||||
|
const [tab, setTab] = useState<Tab>('rules')
|
||||||
|
|
||||||
|
const [rules, setRules] = useState<string[]>([])
|
||||||
|
const [rulesErr, setRulesErr] = useState('')
|
||||||
|
const [rulesLoading, setRulesLoading] = useState(false)
|
||||||
|
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [templatesErr, setTemplatesErr] = useState('')
|
||||||
|
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||||
|
|
||||||
|
const [contracts, setContracts] = useState<Contract[]>([])
|
||||||
|
const [contractsErr, setContractsErr] = useState('')
|
||||||
|
const [contractsLoading, setContractsLoading] = useState(false)
|
||||||
|
|
||||||
|
const [selTemplate, setSelTemplate] = useState('')
|
||||||
|
const [amount, setAmount] = useState('')
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [createErr, setCreateErr] = useState('')
|
||||||
|
|
||||||
|
const loadRules = useCallback(async () => {
|
||||||
|
setRulesLoading(true); setRulesErr('')
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ rules: string[] }>(`${API.ramz}/api/rules`)
|
||||||
|
setRules(data.rules ?? [])
|
||||||
|
} catch (e) { setRulesErr((e as Error).message) }
|
||||||
|
finally { setRulesLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTemplates = useCallback(async () => {
|
||||||
|
setTemplatesLoading(true); setTemplatesErr('')
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<Template[]>(`${API.ramz}/api/templates`)
|
||||||
|
setTemplates(Array.isArray(data) ? data.slice(0, 6) : [])
|
||||||
|
} catch (e) { setTemplatesErr((e as Error).message) }
|
||||||
|
finally { setTemplatesLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadContracts = useCallback(async () => {
|
||||||
|
if (!authToken) return
|
||||||
|
setContractsLoading(true); setContractsErr('')
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<Contract[]>(`${API.ramz}/api/contracts`, authToken)
|
||||||
|
setContracts(Array.isArray(data) ? data : [])
|
||||||
|
} catch (e) { setContractsErr((e as Error).message) }
|
||||||
|
finally { setContractsLoading(false) }
|
||||||
|
}, [authToken])
|
||||||
|
|
||||||
|
useEffect(() => { if (tab === 'rules') loadRules() }, [tab, loadRules])
|
||||||
|
useEffect(() => { if (tab === 'templates') loadTemplates() }, [tab, loadTemplates])
|
||||||
|
useEffect(() => { if (tab === 'contracts') loadContracts() }, [tab, loadContracts])
|
||||||
|
|
||||||
|
const createContract = async () => {
|
||||||
|
if (!selTemplate || !amount || !authToken || !authUser) return
|
||||||
|
setCreating(true); setCreateErr('')
|
||||||
|
try {
|
||||||
|
await apiFetch(`${API.ramz}/api/contracts/create`, authToken, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ templateId: selTemplate, amount: Number(amount), description: 'Demo contract', parties: [authUser.id] }),
|
||||||
|
})
|
||||||
|
setSelTemplate(''); setAmount('')
|
||||||
|
await loadContracts()
|
||||||
|
} catch (e) { setCreateErr((e as Error).message) }
|
||||||
|
finally { setCreating(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="ramz" title="RAMZ Contracts" icon="⚖️" width={480} height={520}>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
|
||||||
|
{(['rules', 'templates', 'contracts'] as Tab[]).map(t => (
|
||||||
|
<button key={t} style={S.tab(tab === t)} onClick={() => setTab(t)}>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rules */}
|
||||||
|
{tab === 'rules' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 20 }}>🛡️</span>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>Active Shariah Rules</span>
|
||||||
|
</div>
|
||||||
|
{rulesLoading && <p style={S.muted}>Loading...</p>}
|
||||||
|
{rulesErr && <p style={S.err}>{rulesErr}</p>}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{rules.map(r => <span key={r} style={S.pill}>{r}</span>)}
|
||||||
|
</div>
|
||||||
|
{!rulesLoading && !rulesErr && rules.length === 0 && <p style={S.muted}>No rules returned.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Templates */}
|
||||||
|
{tab === 'templates' && (
|
||||||
|
<div style={{ overflowY: 'auto', maxHeight: 380 }}>
|
||||||
|
{templatesLoading && <p style={S.muted}>Loading...</p>}
|
||||||
|
{templatesErr && <p style={S.err}>{templatesErr}</p>}
|
||||||
|
{templates.map(t => (
|
||||||
|
<div key={t.id} style={S.card}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13 }}>{t.name}</span>
|
||||||
|
{t.shariahCompliant && <span style={{ ...S.pill, fontSize: 10 }}>Shariah ✓</span>}
|
||||||
|
</div>
|
||||||
|
<p style={{ ...S.muted, marginTop: 4 }}>{t.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!templatesLoading && !templatesErr && templates.length === 0 && <p style={S.muted}>No templates found.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contracts */}
|
||||||
|
{tab === 'contracts' && (
|
||||||
|
<div>
|
||||||
|
{!authToken ? (
|
||||||
|
<p style={{ ...S.muted, textAlign: 'center', marginTop: 40 }}>Log in via Ummah ID to view contracts</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13 }}>Contracts</span>
|
||||||
|
<span style={{ ...S.pill, background: 'rgba(255,255,255,0.08)', color: 'var(--text-muted)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
{contracts.length} Contracts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{contractsLoading && <p style={S.muted}>Loading...</p>}
|
||||||
|
{contractsErr && <p style={S.err}>{contractsErr}</p>}
|
||||||
|
<div style={{ maxHeight: 160, overflowY: 'auto', marginBottom: 14 }}>
|
||||||
|
{contracts.length === 0 && !contractsLoading && (
|
||||||
|
<p style={{ ...S.muted, textAlign: 'center', padding: '16px 0' }}>No contracts yet.</p>
|
||||||
|
)}
|
||||||
|
{contracts.map(c => (
|
||||||
|
<div key={c.id} style={S.card}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600 }}>{c.id.slice(0, 20)}…</div>
|
||||||
|
<div style={S.muted}>Template: {c.templateId} · Amount: {c.amount}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.07)', paddingTop: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Create Contract</div>
|
||||||
|
<select
|
||||||
|
value={selTemplate}
|
||||||
|
onChange={e => setSelTemplate(e.target.value)}
|
||||||
|
style={{ ...S.input, marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<option value="">Select template…</option>
|
||||||
|
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number" placeholder="Amount (FLH)" value={amount}
|
||||||
|
onChange={e => setAmount(e.target.value)}
|
||||||
|
style={{ ...S.input, marginBottom: 10 }}
|
||||||
|
/>
|
||||||
|
{createErr && <p style={S.err}>{createErr}</p>}
|
||||||
|
<button style={{ ...S.btn, opacity: (!selTemplate || !amount || creating) ? 0.5 : 1 }} onClick={createContract} disabled={!selTemplate || !amount || creating}>
|
||||||
|
{creating ? 'Creating…' : 'Create Contract'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type Tab = 'donate' | 'streams' | 'impact'
|
||||||
|
interface Stream { id: string; cause: string; amount: number; frequency: 'monthly' | 'weekly'; status: 'active' | 'paused'; nextPayment: string; donorName: string; createdAt: string }
|
||||||
|
interface Donation { id: string; cause: string; amount: number; type: 'one-time' | 'recurring'; date: string; donorName: string }
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
input: { flex: 1, padding: '8px 11px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(255,255,255,0.05)', color: 'var(--text)', fontSize: '12px', fontFamily: 'inherit', outline: 'none', minWidth: 0 },
|
||||||
|
label: { fontSize: '10px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' as const, letterSpacing: '0.7px', display: 'block', marginBottom: '4px' },
|
||||||
|
btn: { padding: '8px 14px', borderRadius: '8px', border: 'none', background: '#10B981', color: '#fff', fontSize: '12px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' as const },
|
||||||
|
card: { background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '12px', padding: '14px 16px' },
|
||||||
|
}
|
||||||
|
const CAUSES = [
|
||||||
|
{ id: 'education', icon: '🏫', label: 'Education' },
|
||||||
|
{ id: 'health', icon: '🏥', label: 'Health' },
|
||||||
|
{ id: 'water', icon: '💧', label: 'Water' },
|
||||||
|
{ id: 'food', icon: '🍞', label: 'Food' },
|
||||||
|
{ id: 'mosque', icon: '🕌', label: 'Mosque' },
|
||||||
|
{ id: 'general', icon: '🤝', label: 'General' },
|
||||||
|
]
|
||||||
|
const AMOUNT_PRESETS = [10, 25, 50, 100]
|
||||||
|
|
||||||
|
function ErrBox({ msg }: { msg: string }) {
|
||||||
|
return <div style={{ fontSize: '12px', color: '#F87171', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '8px', padding: '9px 12px' }}>{msg}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStreams(): Stream[] {
|
||||||
|
try { return JSON.parse(localStorage.getItem('sadaqah-streams') || '[]') } catch { return [] }
|
||||||
|
}
|
||||||
|
function saveStreams(s: Stream[]) { localStorage.setItem('sadaqah-streams', JSON.stringify(s)) }
|
||||||
|
function loadHistory(): Donation[] {
|
||||||
|
try { return JSON.parse(localStorage.getItem('sadaqah-history') || '[]') } catch { return [] }
|
||||||
|
}
|
||||||
|
function saveHistory(h: Donation[]) { localStorage.setItem('sadaqah-history', JSON.stringify(h)) }
|
||||||
|
|
||||||
|
export default function SadaqahStreamApp() {
|
||||||
|
const { authToken } = useApp()
|
||||||
|
const [tab, setTab] = useState<Tab>('donate')
|
||||||
|
const [customAmount, setCustomAmount] = useState('')
|
||||||
|
const [selectedAmount, setSelectedAmount] = useState(0)
|
||||||
|
const [selectedCause, setSelectedCause] = useState('')
|
||||||
|
const [isRecurring, setIsRecurring] = useState(false)
|
||||||
|
const [donorName, setDonorName] = useState('')
|
||||||
|
const [donateLoading, setDonateLoading] = useState(false)
|
||||||
|
const [donateError, setDonateError] = useState('')
|
||||||
|
const [donateSuccess, setDonateSuccess] = useState(false)
|
||||||
|
const [streams, setStreams] = useState<Stream[]>([])
|
||||||
|
const [history, setHistory] = useState<Donation[]>([])
|
||||||
|
|
||||||
|
const refreshLocal = useCallback(() => {
|
||||||
|
setStreams(loadStreams())
|
||||||
|
setHistory(loadHistory())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { refreshLocal() }, [refreshLocal])
|
||||||
|
|
||||||
|
const handlePreset = (amount: number) => {
|
||||||
|
setSelectedAmount(amount)
|
||||||
|
setCustomAmount('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomChange = (val: string) => {
|
||||||
|
setCustomAmount(val)
|
||||||
|
setSelectedAmount(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = selectedAmount || (customAmount ? Number(customAmount) : 0)
|
||||||
|
|
||||||
|
const handleDonate = async () => {
|
||||||
|
if (!amount || !selectedCause) return
|
||||||
|
if (!authToken) { setDonateError('Please log in with Ummah ID first'); return }
|
||||||
|
setDonateLoading(true); setDonateError(''); setDonateSuccess(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.sadaqah}/api/donate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
cause: selectedCause,
|
||||||
|
amount,
|
||||||
|
recurring: isRecurring,
|
||||||
|
donorName: donorName || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || res.statusText)
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const donation: Donation = {
|
||||||
|
id: data.id || crypto.randomUUID(),
|
||||||
|
cause: selectedCause,
|
||||||
|
amount,
|
||||||
|
type: isRecurring ? 'recurring' : 'one-time',
|
||||||
|
date: now,
|
||||||
|
donorName,
|
||||||
|
}
|
||||||
|
saveHistory([donation, ...loadHistory()])
|
||||||
|
|
||||||
|
if (isRecurring) {
|
||||||
|
const nextPayment = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||||
|
const stream: Stream = {
|
||||||
|
id: data.streamId || crypto.randomUUID(),
|
||||||
|
cause: selectedCause,
|
||||||
|
amount,
|
||||||
|
frequency: 'monthly',
|
||||||
|
status: 'active',
|
||||||
|
nextPayment,
|
||||||
|
donorName,
|
||||||
|
createdAt: now,
|
||||||
|
}
|
||||||
|
saveStreams([stream, ...loadStreams()])
|
||||||
|
}
|
||||||
|
|
||||||
|
setDonateSuccess(true)
|
||||||
|
setSelectedAmount(0)
|
||||||
|
setCustomAmount('')
|
||||||
|
setSelectedCause('')
|
||||||
|
setDonorName('')
|
||||||
|
refreshLocal()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setDonateError(e instanceof Error ? e.message : 'Donation failed')
|
||||||
|
} finally { setDonateLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleStream = (id: string) => {
|
||||||
|
const all = loadStreams().map(s => s.id === id ? { ...s, status: s.status === 'active' ? 'paused' as const : 'active' as const } : s)
|
||||||
|
saveStreams(all)
|
||||||
|
setStreams(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDonated = history.reduce((sum, d) => sum + d.amount, 0)
|
||||||
|
const activeStreams = streams.filter(s => s.status === 'active')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="sadaqah-stream" title="Sadaqah Stream" icon="💧" width={500} height={540}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', background: 'rgba(255,255,255,0.04)', borderRadius: '9px', padding: '4px', marginBottom: '14px' }}>
|
||||||
|
{(['donate', 'streams', 'impact'] as Tab[]).map(t => (
|
||||||
|
<button key={t} onClick={() => setTab(t)} style={{ flex: 1, padding: '7px', borderRadius: '6px', border: 'none', background: tab === t ? '#10B981' : 'transparent', color: tab === t ? '#fff' : 'var(--text-muted)', fontSize: '12px', fontWeight: 600, cursor: 'pointer', textTransform: 'capitalize', fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'donate' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
{donateError && <ErrBox msg={donateError} />}
|
||||||
|
{donateSuccess && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#10B981', background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.2)', borderRadius: '8px', padding: '12px', textAlign: 'center', fontWeight: 600 }}>
|
||||||
|
{isRecurring ? 'Monthly stream created!' : 'Donation successful!'} {' '}
|
||||||
|
{isRecurring ? '🌙' : '✨'}
|
||||||
|
<div style={{ fontSize: '11px', fontWeight: 400, marginTop: '4px', color: 'rgba(16,185,129,0.8)' }}>
|
||||||
|
{isRecurring ? 'Your recurring sadaqah is now active.' : 'May Allah accept your sadaqah.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Amount presets */}
|
||||||
|
<div>
|
||||||
|
<div style={S.label}>Amount (RM)</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
{AMOUNT_PRESETS.map(a => (
|
||||||
|
<button key={a} onClick={() => handlePreset(a)} style={{
|
||||||
|
flex: 1, padding: '10px 0', borderRadius: '8px', border: selectedAmount === a ? '1px solid #10B981' : '1px solid rgba(255,255,255,0.10)',
|
||||||
|
background: selectedAmount === a ? 'rgba(16,185,129,0.12)' : 'rgba(255,255,255,0.04)',
|
||||||
|
color: selectedAmount === a ? '#10B981' : 'var(--text)', fontSize: '13px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>RM{a}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom amount */}
|
||||||
|
<div>
|
||||||
|
<div style={S.label}>Custom Amount</div>
|
||||||
|
<input style={S.input} type="number" placeholder="Enter amount (RM)" value={customAmount} onChange={e => handleCustomChange(e.target.value)} min="0" step="any" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cause grid */}
|
||||||
|
<div>
|
||||||
|
<div style={S.label}>Cause</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
|
||||||
|
{CAUSES.map(c => (
|
||||||
|
<button key={c.id} onClick={() => setSelectedCause(c.id)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '6px', padding: '10px 8px', borderRadius: '8px',
|
||||||
|
border: selectedCause === c.id ? '1px solid #10B981' : '1px solid rgba(255,255,255,0.08)',
|
||||||
|
background: selectedCause === c.id ? 'rgba(16,185,129,0.1)' : 'rgba(255,255,255,0.03)',
|
||||||
|
color: 'var(--text)', fontSize: '11px', fontWeight: selectedCause === c.id ? 600 : 400,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '15px' }}>{c.icon}</span>
|
||||||
|
<span>{c.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recurring toggle */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }} onClick={() => setIsRecurring(!isRecurring)}>
|
||||||
|
<div style={{
|
||||||
|
width: '36px', height: '20px', borderRadius: '99px', background: isRecurring ? '#10B981' : 'rgba(255,255,255,0.12)',
|
||||||
|
position: 'relative', transition: 'background 0.2s', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '16px', height: '16px', borderRadius: '50%', background: '#fff',
|
||||||
|
position: 'absolute', top: '2px', left: isRecurring ? '18px' : '2px',
|
||||||
|
transition: 'left 0.2s',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text)' }}>{isRecurring ? 'Monthly (recurring)' : 'One-time'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Donor name */}
|
||||||
|
<div>
|
||||||
|
<div style={S.label}>Donor Name (optional)</div>
|
||||||
|
<input style={S.input} placeholder="Your name" value={donorName} onChange={e => setDonorName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Donate button */}
|
||||||
|
<button style={{ ...S.btn, width: '100%', padding: '11px', fontSize: '13px', opacity: donateLoading || !amount || !selectedCause ? 0.5 : 1 }} disabled={donateLoading || !amount || !selectedCause} onClick={handleDonate}>
|
||||||
|
{donateLoading ? 'Processing…' : isRecurring ? '🌙 Start Monthly Sadaqah' : '✨ Donate Now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'streams' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{streams.length === 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px 0', gap: '10px', color: 'var(--text-muted)' }}>
|
||||||
|
<span style={{ fontSize: '32px' }}>🌙</span>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text)' }}>No recurring streams</div>
|
||||||
|
<div style={{ fontSize: '12px', textAlign: 'center', maxWidth: '240px', lineHeight: 1.5 }}>Set up a monthly sadaqah from the Donate tab to start a stream.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
streams.map(s => (
|
||||||
|
<div key={s.id} style={S.card}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)', textTransform: 'capitalize' }}>{s.cause}</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px', fontSize: '11px', color: 'var(--text-muted)', marginTop: '4px', flexWrap: 'wrap' }}>
|
||||||
|
<span>RM{s.amount}/{s.frequency === 'monthly' ? 'mo' : 'wk'}</span>
|
||||||
|
<span>Next: {s.nextPayment}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
padding: '3px 8px', borderRadius: '99px', fontSize: '10px', fontWeight: 600,
|
||||||
|
background: s.status === 'active' ? 'rgba(16,185,129,0.15)' : 'rgba(251,191,36,0.15)',
|
||||||
|
color: s.status === 'active' ? '#10B981' : '#FBBF24', flexShrink: 0,
|
||||||
|
}}>{s.status === 'active' ? 'Active' : 'Paused'}</span>
|
||||||
|
<button onClick={() => toggleStream(s.id)} style={{
|
||||||
|
padding: '5px 10px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
background: 'transparent', color: s.status === 'active' ? '#FBBF24' : '#10B981',
|
||||||
|
fontSize: '10px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||||
|
}}>{s.status === 'active' ? 'Pause' : 'Resume'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{activeStreams.length > 0 && (
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', textAlign: 'center', marginTop: '6px' }}>
|
||||||
|
{activeStreams.length} active stream{activeStreams.length !== 1 ? 's' : ''} · Total monthly: RM{activeStreams.reduce((sum, s) => sum + (s.frequency === 'monthly' ? s.amount : s.amount * 4), 0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'impact' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
{/* Stats */}
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<div style={{ ...S.card, flex: 1, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 700, color: '#10B981' }}>RM{totalDonated}</div>
|
||||||
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.6px', marginTop: '2px' }}>Total Donated</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...S.card, flex: 1, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 700, color: '#10B981' }}>{history.length}</div>
|
||||||
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.6px', marginTop: '2px' }}>Donations</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...S.card, flex: 1, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 700, color: '#10B981' }}>{activeStreams.length}</div>
|
||||||
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.6px', marginTop: '2px' }}>Active Streams</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent donations */}
|
||||||
|
<div>
|
||||||
|
<div style={{ ...S.label, marginBottom: '8px' }}>Recent Donations</div>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px 0', color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||||
|
No donations yet. Start with the Donate tab.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{history.slice(0, 10).map(d => (
|
||||||
|
<div key={d.id} style={{ ...S.card, display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 14px' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text)', textTransform: 'capitalize' }}>{d.cause}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '9px', padding: '1px 6px', borderRadius: '99px',
|
||||||
|
background: d.type === 'recurring' ? 'rgba(16,185,129,0.12)' : 'rgba(96,165,250,0.12)',
|
||||||
|
color: d.type === 'recurring' ? '#10B981' : '#60A5FA', fontWeight: 600,
|
||||||
|
}}>{d.type === 'recurring' ? 'recurring' : 'one-time'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '2px' }}>{new Date(d.date).toLocaleDateString('en-MY', { day: 'numeric', month: 'short', year: 'numeric' })}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: 700, color: '#10B981', flexShrink: 0 }}>RM{d.amount}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type Tab = 'register' | 'login'
|
||||||
|
type Role = 'CONSUMER' | 'MERCHANT' | 'REGULATOR'
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
input: { width: '100%', padding: '9px 12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(255,255,255,0.05)', color: 'var(--text)', fontSize: '13px', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' as const },
|
||||||
|
label: { fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' as const, letterSpacing: '0.7px', display: 'block', marginBottom: '5px' },
|
||||||
|
field: { display: 'flex', flexDirection: 'column' as const },
|
||||||
|
btn: { width: '100%', padding: '10px', borderRadius: '9px', border: 'none', background: '#10B981', color: '#fff', fontSize: '13px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<Role, string> = { CONSUMER: '#0284C7', MERCHANT: '#D97706', REGULATOR: '#7C3AED' }
|
||||||
|
|
||||||
|
export default function UmmahIDApp() {
|
||||||
|
const { authToken, authUser, setAuth, clearAuth } = useApp()
|
||||||
|
const [tab, setTab] = useState<Tab>('register')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [fullName, setFullName] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [role, setRole] = useState<Role>('CONSUMER')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError(''); setLoading(true)
|
||||||
|
try {
|
||||||
|
const url = tab === 'register' ? `${API.ummahid}/api/auth/register` : `${API.ummahid}/api/auth/login`
|
||||||
|
const body = tab === 'register' ? { email, password, fullName, role } : { email, password }
|
||||||
|
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || res.statusText)
|
||||||
|
setAuth(data.token, data.user)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Request failed')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="ummahid" title="Ummah ID" icon="🪪" width={400} height={480}>
|
||||||
|
{authToken && authUser ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{/* User card */}
|
||||||
|
<div style={{ background: 'rgba(16,185,129,0.10)', border: '1px solid rgba(16,185,129,0.25)', borderRadius: '12px', padding: '18px', display: 'flex', alignItems: 'center', gap: '14px' }}>
|
||||||
|
<div style={{ width: 48, height: 48, borderRadius: '99px', background: 'linear-gradient(145deg,#10B981,#059669)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '20px', fontWeight: 700, color: '#fff', flexShrink: 0 }}>
|
||||||
|
{authUser.fullName.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text)' }}>{authUser.fullName}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>{authUser.email}</div>
|
||||||
|
<span style={{ display: 'inline-block', marginTop: '6px', padding: '2px 8px', borderRadius: '99px', fontSize: '11px', fontWeight: 600, background: ROLE_COLORS[(authUser.role as Role)] ?? '#475569', color: '#fff' }}>
|
||||||
|
{authUser.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Token preview */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.04)', borderRadius: '10px', padding: '12px 14px' }}>
|
||||||
|
<div style={S.label}>Session Token</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||||
|
{authToken.slice(0, 24)}…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={clearAuth} style={{ ...S.btn, background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.25)', color: '#F87171' }}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div style={{ display: 'flex', gap: '4px', background: 'rgba(255,255,255,0.04)', borderRadius: '9px', padding: '4px' }}>
|
||||||
|
{(['register', 'login'] as Tab[]).map(t => (
|
||||||
|
<button key={t} onClick={() => { setTab(t); setError('') }} style={{ flex: 1, padding: '7px', borderRadius: '6px', border: 'none', background: tab === t ? '#10B981' : 'transparent', color: tab === t ? '#fff' : 'var(--text-muted)', fontSize: '12px', fontWeight: 600, cursor: 'pointer', textTransform: 'capitalize', fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={S.field}>
|
||||||
|
<label style={S.label}>Email</label>
|
||||||
|
<input style={S.input} type="email" placeholder="you@example.com" value={email} onChange={e => setEmail(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{tab === 'register' && (
|
||||||
|
<div style={S.field}>
|
||||||
|
<label style={S.label}>Full Name</label>
|
||||||
|
<input style={S.input} type="text" placeholder="Ahmad bin Abdullah" value={fullName} onChange={e => setFullName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={S.field}>
|
||||||
|
<label style={S.label}>Password</label>
|
||||||
|
<input style={S.input} type="password" placeholder="••••••••" value={password} onChange={e => setPassword(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{tab === 'register' && (
|
||||||
|
<div style={S.field}>
|
||||||
|
<label style={S.label}>Role</label>
|
||||||
|
<select style={{ ...S.input, appearance: 'none' as const }} value={role} onChange={e => setRole(e.target.value as Role)}>
|
||||||
|
<option value="CONSUMER">Consumer</option>
|
||||||
|
<option value="MERCHANT">Merchant</option>
|
||||||
|
<option value="REGULATOR">Regulator (JAKIM)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#F87171', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '8px', padding: '9px 12px' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button style={{ ...S.btn, opacity: loading ? 0.6 : 1 }} disabled={loading} onClick={handleSubmit}>
|
||||||
|
{loading ? 'Loading…' : tab === 'register' ? 'Create Account' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type Tab = 'wallet' | 'fees'
|
||||||
|
interface TestAccount { name: string; role: string; balance: number; address: string }
|
||||||
|
interface FeesData { protocolFeePercent: number; currency: string; description: string }
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
input: { flex: 1, padding: '8px 11px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(255,255,255,0.05)', color: 'var(--text)', fontSize: '12px', fontFamily: 'inherit', outline: 'none', minWidth: 0 },
|
||||||
|
label: { fontSize: '10px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' as const, letterSpacing: '0.7px', display: 'block', marginBottom: '4px' },
|
||||||
|
btn: { padding: '8px 14px', borderRadius: '8px', border: 'none', background: '#10B981', color: '#fff', fontSize: '12px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' as const },
|
||||||
|
card: { background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '12px', padding: '14px 16px' },
|
||||||
|
}
|
||||||
|
const ROLE_COLORS: Record<string, string> = { CONSUMER: '#0284C7', MERCHANT: '#D97706', REGULATOR: '#7C3AED' }
|
||||||
|
const truncAddr = (a: string) => a.length > 12 ? `${a.slice(0, 6)}….${a.slice(-4)}` : a
|
||||||
|
function ErrBox({ msg }: { msg: string }) {
|
||||||
|
return <div style={{ fontSize: '12px', color: '#F87171', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '8px', padding: '9px 12px' }}>{msg}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WalletApp() {
|
||||||
|
const { authToken, authUser } = useApp()
|
||||||
|
const [tab, setTab] = useState<Tab>('wallet')
|
||||||
|
const [walletId, setWalletId] = useState('')
|
||||||
|
const [address, setAddress] = useState('')
|
||||||
|
const [walletLoading, setWalletLoading] = useState(false)
|
||||||
|
const [walletError, setWalletError] = useState('')
|
||||||
|
const [testAccounts, setTestAccounts] = useState<TestAccount[]>([])
|
||||||
|
const [toAddress, setToAddress] = useState('')
|
||||||
|
const [amount, setAmount] = useState('')
|
||||||
|
const [sendLoading, setSendLoading] = useState(false)
|
||||||
|
const [sendError, setSendError] = useState('')
|
||||||
|
const [sendSuccess, setSendSuccess] = useState<{ fee: number; net: number } | null>(null)
|
||||||
|
const [fees, setFees] = useState<FeesData | null>(null)
|
||||||
|
const [feesLoading, setFeesLoading] = useState(false)
|
||||||
|
const [feesError, setFeesError] = useState('')
|
||||||
|
|
||||||
|
const createWallet = useCallback(async () => {
|
||||||
|
if (!authToken || !authUser) return
|
||||||
|
setWalletLoading(true); setWalletError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.wallet}/api/wallet/create`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` }, body: JSON.stringify({ userId: authUser.id, alias: `${authUser.fullName}'s Wallet` }) })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || res.statusText)
|
||||||
|
setWalletId(data.id ?? data.walletId ?? '')
|
||||||
|
setAddress(data.address ?? '')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setWalletError(e instanceof Error ? e.message : 'Failed to load wallet')
|
||||||
|
} finally { setWalletLoading(false) }
|
||||||
|
}, [authToken, authUser])
|
||||||
|
|
||||||
|
const fetchTestAccounts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.mocknet}/test-accounts`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
setTestAccounts(Array.isArray(data) ? data : (data.accounts ?? []))
|
||||||
|
} catch { /* non-critical */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchFees = useCallback(async () => {
|
||||||
|
setFeesLoading(true); setFeesError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.wallet}/api/fees`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || res.statusText)
|
||||||
|
setFees(data)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setFeesError(e instanceof Error ? e.message : 'Failed to load fees')
|
||||||
|
} finally { setFeesLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authToken && authUser && tab === 'wallet' && !walletId) { createWallet(); fetchTestAccounts() }
|
||||||
|
if (tab === 'fees' && !fees) fetchFees()
|
||||||
|
}, [authToken, authUser, tab, walletId, fees, createWallet, fetchTestAccounts, fetchFees])
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!walletId || !toAddress || !amount) return
|
||||||
|
setSendLoading(true); setSendError(''); setSendSuccess(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.wallet}/api/wallet/${walletId}/transfer`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` }, body: JSON.stringify({ toAddress, amount: Number(amount) }) })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || res.statusText)
|
||||||
|
setSendSuccess({ fee: data.fee ?? 0, net: data.netAmount ?? Number(amount) })
|
||||||
|
setAmount('')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setSendError(e instanceof Error ? e.message : 'Transfer failed')
|
||||||
|
} finally { setSendLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
return (
|
||||||
|
<AppWindow id="wallet" title="Wallet" icon="💎" width={420} height={280}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '12px', color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: '32px' }}>🔒</span>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text)' }}>Authentication Required</div>
|
||||||
|
<div style={{ fontSize: '12px', maxWidth: '220px', lineHeight: 1.5 }}>Open Ummah ID app and log in first</div>
|
||||||
|
</div>
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="wallet" title="Wallet" icon="💎" width={420} height={520}>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div style={{ display: 'flex', gap: '4px', background: 'rgba(255,255,255,0.04)', borderRadius: '9px', padding: '4px', marginBottom: '14px' }}>
|
||||||
|
{(['wallet', 'fees'] as Tab[]).map(t => (
|
||||||
|
<button key={t} onClick={() => setTab(t)} style={{ flex: 1, padding: '7px', borderRadius: '6px', border: 'none', background: tab === t ? '#10B981' : 'transparent', color: tab === t ? '#fff' : 'var(--text-muted)', fontSize: '12px', fontWeight: 600, cursor: 'pointer', textTransform: 'capitalize', fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'wallet' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{walletLoading && <div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>Loading…</div>}
|
||||||
|
{walletError && <ErrBox msg={walletError} />}
|
||||||
|
{address && (
|
||||||
|
<div style={{ ...S.card, background: 'linear-gradient(135deg,rgba(16,185,129,0.12),rgba(5,150,105,0.06))' }}>
|
||||||
|
<div style={S.label}>Address</div>
|
||||||
|
<div style={{ fontSize: '13px', fontFamily: 'monospace', color: 'var(--text)', marginBottom: '10px' }}>{truncAddr(address)}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '6px' }}>
|
||||||
|
<span style={{ fontSize: '26px', fontWeight: 700, color: '#10B981' }}>0</span>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>FLH</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testAccounts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ ...S.label, marginBottom: '8px' }}>Mock-Net Accounts</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{testAccounts.map((acc, i) => (
|
||||||
|
<div key={i} style={{ ...S.card, display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 14px' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text)' }}>{acc.name}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>{acc.balance ?? 0} FLH</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ padding: '2px 7px', borderRadius: '99px', fontSize: '10px', fontWeight: 600, background: ROLE_COLORS[acc.role] ?? '#475569', color: '#fff', flexShrink: 0 }}>{acc.role}</span>
|
||||||
|
<button onClick={() => setToAddress(acc.address ?? '')} style={{ ...S.btn, padding: '5px 10px', fontSize: '11px', flexShrink: 0 }}>Send to</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{walletId && (
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={{ ...S.label, marginBottom: '8px' }}>Transfer FLH</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
<input style={S.input} placeholder="Recipient address" value={toAddress} onChange={e => setToAddress(e.target.value)} />
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<input style={S.input} type="number" placeholder="Amount (FLH)" value={amount} onChange={e => setAmount(e.target.value)} min="0" step="any" />
|
||||||
|
<button style={{ ...S.btn, opacity: sendLoading ? 0.6 : 1 }} disabled={sendLoading} onClick={handleSend}>{sendLoading ? '…' : 'Send'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sendError && <div style={{ fontSize: '11px', color: '#F87171', marginTop: '8px' }}>{sendError}</div>}
|
||||||
|
{sendSuccess && <div style={{ fontSize: '11px', color: '#10B981', marginTop: '8px', background: 'rgba(16,185,129,0.08)', borderRadius: '6px', padding: '7px 10px' }}>Sent! Net: {sendSuccess.net} FLH · Fee: {sendSuccess.fee} FLH</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'fees' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{feesLoading && <div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>Loading…</div>}
|
||||||
|
{feesError && <ErrBox msg={feesError} />}
|
||||||
|
{fees && (
|
||||||
|
<div style={{ ...S.card, display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<div><div style={S.label}>Protocol Fee</div><div style={{ fontSize: '28px', fontWeight: 700, color: '#10B981' }}>{fees.protocolFeePercent}%</div></div>
|
||||||
|
<div><div style={S.label}>Currency</div><div style={{ fontSize: '14px', color: 'var(--text)', fontWeight: 600 }}>{fees.currency}</div></div>
|
||||||
|
{fees.description && <div><div style={S.label}>Description</div><div style={{ fontSize: '12px', color: 'var(--text-muted)', lineHeight: 1.5 }}>{fees.description}</div></div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API } from '../hooks/useApi'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type Tab = 'explore' | 'create' | 'contributions'
|
||||||
|
|
||||||
|
interface WaqfProject {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
targetAmount: number
|
||||||
|
raisedAmount: number
|
||||||
|
donorCount: number
|
||||||
|
nadzirName: string
|
||||||
|
nadzirContact: string
|
||||||
|
createdAt: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Contribution {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
|
projectTitle: string
|
||||||
|
amount: number
|
||||||
|
donorName: string
|
||||||
|
anonymous: boolean
|
||||||
|
transactionHash: string
|
||||||
|
createdAt: string
|
||||||
|
certificateUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES = ['Education', 'Health', 'Mosque', 'Water', 'Community'] as const
|
||||||
|
const CAT_COLORS: Record<string, string> = { Education: '#0284C7', Health: '#EF4444', Mosque: '#10B981', Water: '#3B82F6', Community: '#D97706' }
|
||||||
|
|
||||||
|
const SEED_PROJECTS: WaqfProject[] = [
|
||||||
|
{ id: 'seed-1', title: 'Al-Falah Islamic School', description: 'Building a new classroom block for underprivileged students in the community.', category: 'Education', targetAmount: 50000, raisedAmount: 31500, donorCount: 89, nadzirName: 'Yayasan Falah', nadzirContact: 'admin@falah.org', createdAt: new Date(Date.now() - 2592000000).toISOString(), status: 'ACTIVE' },
|
||||||
|
{ id: 'seed-2', title: 'Desa Sehat Water Well', description: 'Clean water access for 200 families in rural East Java.', category: 'Water', targetAmount: 12000, raisedAmount: 12000, donorCount: 210, nadzirName: 'Yayasan Falah', nadzirContact: 'admin@falah.org', createdAt: new Date(Date.now() - 5184000000).toISOString(), status: 'COMPLETED' },
|
||||||
|
{ id: 'seed-3', title: 'An-Nur Mosque Renovation', description: 'Renovating the main prayer hall and replacing the roof.', category: 'Mosque', targetAmount: 80000, raisedAmount: 42000, donorCount: 156, nadzirName: 'Masjid An-Nur', nadzirContact: 'nur@example.com', createdAt: new Date(Date.now() - 345600000).toISOString(), status: 'ACTIVE' },
|
||||||
|
{ id: 'seed-4', title: 'Mobile Clinic Program', description: 'Funding a mobile health clinic for remote villages.', category: 'Health', targetAmount: 35000, raisedAmount: 8700, donorCount: 43, nadzirName: 'Yayasan Falah', nadzirContact: 'admin@falah.org', createdAt: new Date(Date.now() - 172800000).toISOString(), status: 'ACTIVE' },
|
||||||
|
{ id: 'seed-5', title: 'Community Learning Center', description: 'Establishing a library and computer lab for youth skills training.', category: 'Community', targetAmount: 22000, raisedAmount: 22000, donorCount: 178, nadzirName: 'Komunitas Belajar', nadzirContact: 'learn@example.com', createdAt: new Date(Date.now() - 7776000000).toISOString(), status: 'COMPLETED' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
input: { width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(255,255,255,0.05)', color: 'var(--text)', fontSize: '12px', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' as const },
|
||||||
|
label: { fontSize: '10px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' as const, letterSpacing: '0.7px', display: 'block', marginBottom: '4px' },
|
||||||
|
btn: { padding: '8px 14px', borderRadius: '8px', border: 'none', background: '#10B981', color: '#fff', fontSize: '12px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' as const },
|
||||||
|
card: { background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '12px', padding: '14px 16px' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrBox({ msg }: { msg: string }) {
|
||||||
|
return <div style={{ fontSize: '12px', color: '#F87171', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '8px', padding: '9px 12px' }}>{msg}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCurrency(n: number) {
|
||||||
|
return new Intl.NumberFormat('en-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string) {
|
||||||
|
try { return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) }
|
||||||
|
catch { return iso }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WaqfChainApp() {
|
||||||
|
const { authToken, authUser } = useApp()
|
||||||
|
const [tab, setTab] = useState<Tab>('explore')
|
||||||
|
const [projects, setProjects] = useState<WaqfProject[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
// Donate modal
|
||||||
|
const [donateProject, setDonateProject] = useState<WaqfProject | null>(null)
|
||||||
|
const [donateAmount, setDonateAmount] = useState('')
|
||||||
|
const [donorName, setDonorName] = useState('')
|
||||||
|
const [donateAnonymous, setDonateAnonymous] = useState(false)
|
||||||
|
const [donateLoading, setDonateLoading] = useState(false)
|
||||||
|
const [donateSuccess, setDonateSuccess] = useState(false)
|
||||||
|
const [donateErr, setDonateErr] = useState('')
|
||||||
|
|
||||||
|
// Create form
|
||||||
|
const [cTitle, setCTitle] = useState('')
|
||||||
|
const [cDesc, setCDesc] = useState('')
|
||||||
|
const [cCategory, setCCategory] = useState('Education')
|
||||||
|
const [cTarget, setCTarget] = useState('')
|
||||||
|
const [cNadzirName, setCNadzirName] = useState('')
|
||||||
|
const [cNadzirContact, setCNadzirContact] = useState('')
|
||||||
|
const [createLoading, setCreateLoading] = useState(false)
|
||||||
|
const [createSuccess, setCreateSuccess] = useState('')
|
||||||
|
const [createErr, setCreateErr] = useState('')
|
||||||
|
|
||||||
|
// Contributions
|
||||||
|
const [contributions, setContributions] = useState<Contribution[]>([])
|
||||||
|
const [contribLoading, setContribLoading] = useState(false)
|
||||||
|
const [contribErr, setContribErr] = useState('')
|
||||||
|
const [certificate, setCertificate] = useState<Contribution | null>(null)
|
||||||
|
|
||||||
|
const loadProjects = useCallback(async () => {
|
||||||
|
setLoading(true); setErr('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.waqf}/api/projects`)
|
||||||
|
if (!res.ok) throw new Error('Server error')
|
||||||
|
const data = await res.json()
|
||||||
|
setProjects(Array.isArray(data) ? data : data.projects ?? SEED_PROJECTS)
|
||||||
|
} catch {
|
||||||
|
setProjects(SEED_PROJECTS)
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadContributions = useCallback(async () => {
|
||||||
|
if (!authToken) return
|
||||||
|
setContribLoading(true); setContribErr('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.waqf}/api/contributions`, { headers: { Authorization: `Bearer ${authToken}` } })
|
||||||
|
if (!res.ok) throw new Error('Failed to load contributions')
|
||||||
|
const data = await res.json()
|
||||||
|
setContributions(Array.isArray(data) ? data : data.contributions ?? [])
|
||||||
|
} catch {
|
||||||
|
const stored = localStorage.getItem('waqf_contributions')
|
||||||
|
if (stored) try { setContributions(JSON.parse(stored) as Contribution[]) } catch { /* ignore */ }
|
||||||
|
} finally { setContribLoading(false) }
|
||||||
|
}, [authToken])
|
||||||
|
|
||||||
|
useEffect(() => { loadProjects() }, [loadProjects])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'contributions' && authToken) loadContributions()
|
||||||
|
}, [tab, authToken, loadContributions])
|
||||||
|
|
||||||
|
const handleDonate = async () => {
|
||||||
|
if (!donateProject || !donateAmount) return
|
||||||
|
setDonateLoading(true); setDonateErr(''); setDonateSuccess(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.waqf}/api/projects/${donateProject.id}/donate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) },
|
||||||
|
body: JSON.stringify({ amount: Number(donateAmount), donorName: donorName || (authUser?.fullName ?? 'Anonymous'), anonymous: donateAnonymous }),
|
||||||
|
})
|
||||||
|
if (!res.ok) { const d = await res.json(); throw new Error(d.error || res.statusText) }
|
||||||
|
const result = await res.json()
|
||||||
|
setDonateSuccess(true)
|
||||||
|
setProjects(prev => prev.map(p => p.id === donateProject.id ? { ...p, raisedAmount: p.raisedAmount + Number(donateAmount), donorCount: p.donorCount + 1 } : p))
|
||||||
|
if (result.contribution) {
|
||||||
|
const newContrib: Contribution = { id: result.contribution.id ?? '', projectId: donateProject.id, projectTitle: donateProject.title, amount: Number(donateAmount), donorName: donorName || (authUser?.fullName ?? 'Anonymous'), anonymous: donateAnonymous, transactionHash: result.contribution.transactionHash ?? '', createdAt: new Date().toISOString(), certificateUrl: result.contribution.certificateUrl }
|
||||||
|
setContributions(prev => [newContrib, ...prev])
|
||||||
|
localStorage.setItem('waqf_contributions', JSON.stringify([newContrib, ...contributions]))
|
||||||
|
}
|
||||||
|
setTimeout(() => { setDonateProject(null); setDonateAmount(''); setDonorName(''); setDonateAnonymous(false); setDonateSuccess(false) }, 2000)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setDonateErr(e instanceof Error ? e.message : 'Donation failed')
|
||||||
|
} finally { setDonateLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateProject = async () => {
|
||||||
|
setCreateErr(''); setCreateSuccess('')
|
||||||
|
if (!cTitle.trim() || !cDesc.trim() || !cTarget || !cNadzirName.trim() || !cNadzirContact.trim()) { setCreateErr('All fields required'); return }
|
||||||
|
setCreateLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.waqf}/api/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) },
|
||||||
|
body: JSON.stringify({ title: cTitle, description: cDesc, category: cCategory, targetAmount: Number(cTarget), nadzirName: cNadzirName, nadzirContact: cNadzirContact }),
|
||||||
|
})
|
||||||
|
if (!res.ok) { const d = await res.json(); throw new Error(d.error || res.statusText) }
|
||||||
|
const project = await res.json()
|
||||||
|
setCreateSuccess('Project created! Redirecting…')
|
||||||
|
setCTitle(''); setCDesc(''); setCTarget(''); setCNadzirName(''); setCNadzirContact('')
|
||||||
|
setProjects(prev => [project, ...prev])
|
||||||
|
setTimeout(() => { setTab('explore'); setCreateSuccess('') }, 1500)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setCreateErr(e instanceof Error ? e.message : 'Failed to create project')
|
||||||
|
} finally { setCreateLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = (p: WaqfProject) => Math.min(100, Math.round((p.raisedAmount / p.targetAmount) * 100))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="waqf-chain" title="Waqf Chain" icon="🔗" width={560} height={580}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', background: 'rgba(255,255,255,0.04)', borderRadius: '9px', padding: '4px', marginBottom: '14px' }}>
|
||||||
|
{(['explore', 'create', 'contributions'] as Tab[]).map(t => (
|
||||||
|
<button key={t} onClick={() => { setErr(''); setTab(t) }} style={{ flex: 1, padding: '7px', borderRadius: '6px', border: 'none', background: tab === t ? '#10B981' : 'transparent', color: tab === t ? '#fff' : 'var(--text-muted)', fontSize: '12px', fontWeight: 600, cursor: 'pointer', textTransform: 'capitalize', fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div style={{ fontSize: '12px', color: '#F87171', marginBottom: '10px' }}>{err}</div>}
|
||||||
|
|
||||||
|
{/* EXPLORE */}
|
||||||
|
{tab === 'explore' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', overflowY: 'auto', maxHeight: '440px' }}>
|
||||||
|
{loading && <div style={{ fontSize: '13px', color: 'var(--text-muted)', textAlign: 'center', padding: '30px 0' }}>Loading…</div>}
|
||||||
|
{!loading && projects.length === 0 && <div style={{ fontSize: '13px', color: 'var(--text-muted)', textAlign: 'center', padding: '40px 0' }}>No waqf projects yet</div>}
|
||||||
|
{projects.map(p => (
|
||||||
|
<div key={p.id} style={S.card}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '10px', marginBottom: '8px' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)' }}>{p.title}</span>
|
||||||
|
<span style={{ fontSize: '10px', fontWeight: 600, padding: '2px 7px', borderRadius: '99px', background: CAT_COLORS[p.category] ?? 'rgba(255,255,255,0.08)', color: '#fff' }}>{p.category}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', margin: '3px 0', lineHeight: 1.4 }}>{p.description.length > 80 ? p.description.slice(0, 80) + '…' : p.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{fmtCurrency(p.raisedAmount)} raised</span>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>target {fmtCurrency(p.targetAmount)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '100%', height: '6px', background: 'rgba(255,255,255,0.08)', borderRadius: '99px', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${progress(p)}%`, height: '100%', background: p.status === 'COMPLETED' ? '#6366F1' : '#10B981', borderRadius: '99px', transition: 'width 0.4s ease' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{p.donorCount} donors · Nadzir: {p.nadzirName}</span>
|
||||||
|
<button onClick={() => { setDonateProject(p); setDonateAmount(''); setDonorName(''); setDonateAnonymous(false); setDonateSuccess(false); setDonateErr('') }} style={{ ...S.btn, padding: '5px 12px', fontSize: '11px', background: p.status === 'COMPLETED' ? 'rgba(99,102,241,0.3)' : '#10B981', opacity: p.status === 'COMPLETED' ? 0.6 : 1 }} disabled={p.status === 'COMPLETED'}>{p.status === 'COMPLETED' ? 'Completed' : 'Donate'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CREATE */}
|
||||||
|
{tab === 'create' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', overflowY: 'auto', maxHeight: '440px' }}>
|
||||||
|
{createSuccess && <div style={{ fontSize: '12px', color: '#10B981', background: 'rgba(16,185,129,0.08)', borderRadius: '8px', padding: '8px 12px' }}>{createSuccess}</div>}
|
||||||
|
{createErr && <ErrBox msg={createErr} />}
|
||||||
|
<div><label style={S.label}>Title</label><input style={S.input} value={cTitle} onChange={e => setCTitle(e.target.value)} placeholder="Waqf project name" /></div>
|
||||||
|
<div><label style={S.label}>Description</label><textarea style={{ ...S.input, resize: 'vertical', minHeight: '60px' }} value={cDesc} onChange={e => setCDesc(e.target.value)} placeholder="Describe the project" /></div>
|
||||||
|
<div><label style={S.label}>Category</label>
|
||||||
|
<select style={{ ...S.input }} value={cCategory} onChange={e => setCCategory(e.target.value)}>
|
||||||
|
{CATEGORIES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><label style={S.label}>Target Amount (IDR)</label><input style={S.input} type="number" value={cTarget} onChange={e => setCTarget(e.target.value)} placeholder="e.g. 50000000" min="0" /></div>
|
||||||
|
<div><label style={S.label}>Nadzir Name</label><input style={S.input} value={cNadzirName} onChange={e => setCNadzirName(e.target.value)} placeholder="Institution or person" /></div>
|
||||||
|
<div><label style={S.label}>Nadzir Contact</label><input style={S.input} value={cNadzirContact} onChange={e => setCNadzirContact(e.target.value)} placeholder="Phone or email" /></div>
|
||||||
|
<button style={{ ...S.btn, opacity: createLoading ? 0.6 : 1 }} disabled={createLoading} onClick={handleCreateProject}>{createLoading ? 'Submitting…' : 'Create Project'}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CONTRIBUTIONS */}
|
||||||
|
{tab === 'contributions' && !authToken && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '200px', gap: '10px', color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||||
|
<span style={{ fontSize: '32px' }}>🔒</span>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text)' }}>Login Required</div>
|
||||||
|
<div style={{ fontSize: '12px' }}>Open Ummah ID app and log in first</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'contributions' && authToken && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{contribLoading && <div style={{ fontSize: '13px', color: 'var(--text-muted)', textAlign: 'center', padding: '20px 0' }}>Loading…</div>}
|
||||||
|
{contribErr && <ErrBox msg={contribErr} />}
|
||||||
|
{!contribLoading && contributions.length === 0 && <div style={{ fontSize: '13px', color: 'var(--text-muted)', textAlign: 'center', padding: '40px 0' }}>No contributions yet</div>}
|
||||||
|
{contributions.length > 0 && (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '11px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px', color: 'var(--text-muted)', fontWeight: 600 }}>Date</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px', color: 'var(--text-muted)', fontWeight: 600 }}>Project</th>
|
||||||
|
<th style={{ textAlign: 'right', padding: '6px 8px', color: 'var(--text-muted)', fontWeight: 600 }}>Amount</th>
|
||||||
|
<th style={{ textAlign: 'center', padding: '6px 8px', color: 'var(--text-muted)', fontWeight: 600 }}>Certificate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{contributions.map(c => (
|
||||||
|
<tr key={c.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||||
|
<td style={{ padding: '8px', color: 'var(--text-muted)' }}>{fmtDate(c.createdAt)}</td>
|
||||||
|
<td style={{ padding: '8px', color: 'var(--text)', fontWeight: 500 }}>{c.projectTitle}</td>
|
||||||
|
<td style={{ padding: '8px', color: '#10B981', textAlign: 'right', fontWeight: 600 }}>{fmtCurrency(c.amount)}</td>
|
||||||
|
<td style={{ padding: '8px', textAlign: 'center' }}>
|
||||||
|
<button onClick={() => setCertificate(c)} style={{ ...S.btn, padding: '4px 10px', fontSize: '10px', background: 'rgba(99,102,241,0.2)', color: '#818CF8' }}>View</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Donate Modal */}
|
||||||
|
{donateProject && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100, borderRadius: '14px' }}>
|
||||||
|
<div style={{ background: 'rgba(12,26,46,0.96)', border: '1px solid rgba(255,255,255,0.10)', borderRadius: '14px', padding: '20px', width: '300px', maxWidth: '90%' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '14px' }}>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text)' }}>Donate to {donateProject.title}</span>
|
||||||
|
<button onClick={() => setDonateProject(null)} style={{ width: '18px', height: '18px', borderRadius: '50%', background: 'rgba(255,80,80,0.6)', border: 'none', cursor: 'pointer', fontSize: '10px', color: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
{donateSuccess ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||||
|
<span style={{ fontSize: '28px' }}>✅</span>
|
||||||
|
<div style={{ fontSize: '14px', color: '#10B981', fontWeight: 600, marginTop: '8px' }}>Donation Successful!</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>{fmtCurrency(Number(donateAmount))} contributed</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<div><label style={S.label}>Amount (IDR)</label><input style={S.input} type="number" value={donateAmount} onChange={e => setDonateAmount(e.target.value)} placeholder="e.g. 100000" min="0" /></div>
|
||||||
|
<div><label style={S.label}>Your Name</label><input style={S.input} value={donorName} onChange={e => setDonorName(e.target.value)} placeholder={authUser?.fullName ?? 'Anonymous'} /></div>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: 'var(--text-muted)', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={donateAnonymous} onChange={e => setDonateAnonymous(e.target.checked)} style={{ accentColor: '#10B981' }} />
|
||||||
|
Donate anonymously
|
||||||
|
</label>
|
||||||
|
{donateErr && <ErrBox msg={donateErr} />}
|
||||||
|
<button style={{ ...S.btn, opacity: donateLoading ? 0.6 : 1 }} disabled={donateLoading} onClick={handleDonate}>{donateLoading ? 'Processing…' : 'Confirm Donation'}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Certificate Modal */}
|
||||||
|
{certificate && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100, borderRadius: '14px' }}>
|
||||||
|
<div style={{ background: 'rgba(12,26,46,0.96)', border: '1px solid rgba(255,255,255,0.10)', borderRadius: '14px', padding: '24px', width: '320px', maxWidth: '90%', textAlign: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '14px' }}>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text)' }}>Digital Certificate</span>
|
||||||
|
<button onClick={() => setCertificate(null)} style={{ width: '18px', height: '18px', borderRadius: '50%', background: 'rgba(255,80,80,0.6)', border: 'none', cursor: 'pointer', fontSize: '10px', color: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '40px' }}>📜</span>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)', marginTop: '10px' }}>Waqf Contribution</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#10B981', fontWeight: 700, margin: '6px 0' }}>{fmtCurrency(certificate.amount)}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '4px' }}>{certificate.projectTitle}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{certificate.donorName}{certificate.anonymous ? ' (Anonymous)' : ''}</div>
|
||||||
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '8px', fontFamily: 'monospace' }}>TX: {(certificate.transactionHash ?? '').length > 16 ? certificate.transactionHash.slice(0, 16) + '…' : certificate.transactionHash || '—'}</div>
|
||||||
|
{certificate.certificateUrl && (
|
||||||
|
<a href={certificate.certificateUrl} target="_blank" rel="noopener noreferrer" style={{ ...S.btn, display: 'inline-block', marginTop: '12px', textDecoration: 'none', fontSize: '11px' }}>Download Certificate</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import AppWindow from '../windows/AppWindow'
|
||||||
|
import { API } from '../hooks/useApi'
|
||||||
|
|
||||||
|
|
||||||
|
type Tab = 'calculator' | 'nisab' | 'records'
|
||||||
|
type CalcStep = 1 | 2 | 3
|
||||||
|
|
||||||
|
interface CalcResults {
|
||||||
|
totalAssets: number
|
||||||
|
totalLiabilities: number
|
||||||
|
netWealth: number
|
||||||
|
goldNisab: number
|
||||||
|
silverNisab: number
|
||||||
|
zakatDue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NisabRates {
|
||||||
|
goldPricePerG: number
|
||||||
|
silverPricePerG: number
|
||||||
|
goldNisabG: number
|
||||||
|
silverNisabG: number
|
||||||
|
goldNisabValue: number
|
||||||
|
silverNisabValue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZakatRecord {
|
||||||
|
date: string
|
||||||
|
totalAssets: number
|
||||||
|
totalLiabilities: number
|
||||||
|
netWealth: number
|
||||||
|
nisabThreshold: number
|
||||||
|
zakatDue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const S = {
|
||||||
|
input: { flex: 1, padding: '8px 11px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(255,255,255,0.05)', color: 'var(--text)', fontSize: '12px', fontFamily: 'inherit', outline: 'none', minWidth: 0, width: '100%', boxSizing: 'border-box' as const },
|
||||||
|
label: { fontSize: '10px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' as const, letterSpacing: '0.7px', display: 'block', marginBottom: '4px' },
|
||||||
|
btn: { padding: '8px 14px', borderRadius: '8px', border: 'none', background: '#10B981', color: '#fff', fontSize: '12px', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' as const },
|
||||||
|
card: { background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '12px', padding: '14px 16px' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const GOLD_NISAB_G = 87.48
|
||||||
|
const SILVER_NISAB_G = 612.36
|
||||||
|
const GOLD_PRICE = 360
|
||||||
|
const SILVER_PRICE = 4.32
|
||||||
|
const GOLD_NISAB_VAL = +(GOLD_NISAB_G * GOLD_PRICE).toFixed(2)
|
||||||
|
const SILVER_NISAB_VAL = +(SILVER_NISAB_G * SILVER_PRICE).toFixed(2)
|
||||||
|
const ZAKAT_RATE = 0.025
|
||||||
|
|
||||||
|
function ErrBox({ msg }: { msg: string }) {
|
||||||
|
return <div style={{ fontSize: '12px', color: '#F87171', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '8px', padding: '9px 12px' }}>{msg}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const nf = (n: number) => n.toLocaleString('en-MY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
|
||||||
|
const ASSET_FIELDS: { key: string; label: string; placeholder: string }[] = [
|
||||||
|
{ key: 'cash', label: 'Cash & Bank Savings', placeholder: '0.00' },
|
||||||
|
{ key: 'gold_g', label: 'Gold (grams)', placeholder: '0.00' },
|
||||||
|
{ key: 'silver_g', label: 'Silver (grams)', placeholder: '0.00' },
|
||||||
|
{ key: 'stocks', label: 'Stocks & Shares', placeholder: '0.00' },
|
||||||
|
{ key: 'business_value', label: 'Business Inventory Value', placeholder: '0.00' },
|
||||||
|
{ key: 'crypto', label: 'Cryptocurrency', placeholder: '0.00' },
|
||||||
|
{ key: 'rental_income', label: 'Rental Income Receivable', placeholder: '0.00' },
|
||||||
|
{ key: 'agriculture_value', label: 'Agricultural Produce Value', placeholder: '0.00' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LIABILITY_FIELDS: { key: string; label: string; placeholder: string }[] = [
|
||||||
|
{ key: 'debts', label: 'Outstanding Debts', placeholder: '0.00' },
|
||||||
|
{ key: 'expenses_next_year', label: 'Expenses Due Within 1 Year', placeholder: '0.00' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ZakatVaultApp() {
|
||||||
|
const [tab, setTab] = useState<Tab>('calculator')
|
||||||
|
const [step, setStep] = useState<CalcStep>(1)
|
||||||
|
const [results, setResults] = useState<CalcResults | null>(null)
|
||||||
|
const [records, setRecords] = useState<ZakatRecord[]>([])
|
||||||
|
const [assets, setAssets] = useState<Record<string, string>>({
|
||||||
|
cash: '', gold_g: '', silver_g: '', stocks: '', business_value: '', crypto: '', rental_income: '', agriculture_value: '',
|
||||||
|
})
|
||||||
|
const [liabilities, setLiabilities] = useState<Record<string, string>>({ debts: '', expenses_next_year: '' })
|
||||||
|
const [nisabRates, setNisabRates] = useState<NisabRates | null>(null)
|
||||||
|
const [nisabLoading, setNisabLoading] = useState(false)
|
||||||
|
const [nisabError, setNisabError] = useState('')
|
||||||
|
|
||||||
|
const fetchNisab = useCallback(async () => {
|
||||||
|
setNisabLoading(true); setNisabError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.zakat}/api/nisab`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || res.statusText)
|
||||||
|
setNisabRates({
|
||||||
|
goldPricePerG: +(data.gold.myr / data.gold.grams).toFixed(2),
|
||||||
|
silverPricePerG: +(data.silver.myr / data.silver.grams).toFixed(2),
|
||||||
|
goldNisabG: data.gold.grams,
|
||||||
|
silverNisabG: data.silver.grams,
|
||||||
|
goldNisabValue: data.gold.myr,
|
||||||
|
silverNisabValue: data.silver.myr,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setNisabRates({
|
||||||
|
goldPricePerG: GOLD_PRICE,
|
||||||
|
silverPricePerG: SILVER_PRICE,
|
||||||
|
goldNisabG: GOLD_NISAB_G,
|
||||||
|
silverNisabG: SILVER_NISAB_G,
|
||||||
|
goldNisabValue: GOLD_NISAB_VAL,
|
||||||
|
silverNisabValue: SILVER_NISAB_VAL,
|
||||||
|
})
|
||||||
|
} finally { setNisabLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'nisab' && !nisabRates) fetchNisab()
|
||||||
|
}, [tab, nisabRates, fetchNisab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('zakat-history')
|
||||||
|
if (stored) setRecords(JSON.parse(stored))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCalculate = () => {
|
||||||
|
const goldG = parseFloat(assets.gold_g) || 0
|
||||||
|
const silverG = parseFloat(assets.silver_g) || 0
|
||||||
|
const vals = {
|
||||||
|
cash: parseFloat(assets.cash) || 0,
|
||||||
|
gold_g: goldG * GOLD_PRICE,
|
||||||
|
silver_g: silverG * SILVER_PRICE,
|
||||||
|
stocks: parseFloat(assets.stocks) || 0,
|
||||||
|
business_value: parseFloat(assets.business_value) || 0,
|
||||||
|
crypto: parseFloat(assets.crypto) || 0,
|
||||||
|
rental_income: parseFloat(assets.rental_income) || 0,
|
||||||
|
agriculture_value: parseFloat(assets.agriculture_value) || 0,
|
||||||
|
}
|
||||||
|
const totalAssets = Object.values(vals).reduce((a, b) => a + b, 0)
|
||||||
|
const totalLiabilities = (parseFloat(liabilities.debts) || 0) + (parseFloat(liabilities.expenses_next_year) || 0)
|
||||||
|
const netWealth = totalAssets - totalLiabilities
|
||||||
|
const nisabThreshold = Math.min(GOLD_NISAB_VAL, SILVER_NISAB_VAL)
|
||||||
|
const zakatDue = netWealth > nisabThreshold ? +(netWealth * ZAKAT_RATE).toFixed(2) : 0
|
||||||
|
|
||||||
|
const res: CalcResults = {
|
||||||
|
totalAssets: +totalAssets.toFixed(2),
|
||||||
|
totalLiabilities: +totalLiabilities.toFixed(2),
|
||||||
|
netWealth: +netWealth.toFixed(2),
|
||||||
|
goldNisab: GOLD_NISAB_VAL,
|
||||||
|
silverNisab: SILVER_NISAB_VAL,
|
||||||
|
zakatDue,
|
||||||
|
}
|
||||||
|
setResults(res)
|
||||||
|
setStep(3)
|
||||||
|
|
||||||
|
const record: ZakatRecord = {
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
totalAssets: res.totalAssets,
|
||||||
|
totalLiabilities: res.totalLiabilities,
|
||||||
|
netWealth: res.netWealth,
|
||||||
|
nisabThreshold,
|
||||||
|
zakatDue,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const existing: ZakatRecord[] = JSON.parse(localStorage.getItem('zakat-history') || '[]')
|
||||||
|
existing.unshift(record)
|
||||||
|
localStorage.setItem('zakat-history', JSON.stringify(existing.slice(0, 50)))
|
||||||
|
setRecords(existing.slice(0, 50))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearRecords = () => {
|
||||||
|
localStorage.removeItem('zakat-history')
|
||||||
|
setRecords([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setStep(1)
|
||||||
|
setResults(null)
|
||||||
|
setAssets({ cash: '', gold_g: '', silver_g: '', stocks: '', business_value: '', crypto: '', rental_income: '', agriculture_value: '' })
|
||||||
|
setLiabilities({ debts: '', expenses_next_year: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppWindow id="zakat-vault" title="Zakat Vault" icon="🕌" width={500} height={560}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', background: 'rgba(255,255,255,0.04)', borderRadius: '9px', padding: '4px', marginBottom: '14px' }}>
|
||||||
|
{(['calculator', 'nisab', 'records'] as Tab[]).map(t => (
|
||||||
|
<button key={t} onClick={() => setTab(t)} style={{ flex: 1, padding: '7px', borderRadius: '6px', border: 'none', background: tab === t ? '#10B981' : 'transparent', color: tab === t ? '#fff' : 'var(--text-muted)', fontSize: '12px', fontWeight: 600, cursor: 'pointer', textTransform: 'capitalize', fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>{t === 'nisab' ? 'Nisab' : t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'calculator' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||||
|
Step {step} of 3 — {step === 1 ? 'Assets' : step === 2 ? 'Liabilities' : 'Results'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{ASSET_FIELDS.map(f => (
|
||||||
|
<div key={f.key}>
|
||||||
|
<label style={S.label}>{f.label}</label>
|
||||||
|
<input style={S.input} type="number" placeholder={f.placeholder} value={assets[f.key] || ''} onChange={e => setAssets({ ...assets, [f.key]: e.target.value })} min="0" step="any" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
|
||||||
|
<button style={{ ...S.btn, background: 'rgba(255,255,255,0.08)', color: 'var(--text-muted)' }} disabled>Back</button>
|
||||||
|
<button style={S.btn} onClick={() => setStep(2)}>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{LIABILITY_FIELDS.map(f => (
|
||||||
|
<div key={f.key}>
|
||||||
|
<label style={S.label}>{f.label}</label>
|
||||||
|
<input style={S.input} type="number" placeholder={f.placeholder} value={liabilities[f.key] || ''} onChange={e => setLiabilities({ ...liabilities, [f.key]: e.target.value })} min="0" step="any" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px', marginTop: '4px' }}>
|
||||||
|
<button style={{ ...S.btn, background: 'rgba(255,255,255,0.08)', color: 'var(--text-muted)' }} onClick={() => setStep(1)}>Back</button>
|
||||||
|
<button style={S.btn} onClick={() => { if (step === 2) handleCalculate(); else setStep(3) }}>Calculate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && results && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Total Assets</span>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)' }}>RM {nf(results.totalAssets)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Total Liabilities</span>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)' }}>RM {nf(results.totalLiabilities)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Net Wealth</span>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 700, color: '#10B981' }}>RM {nf(results.netWealth)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Gold Nisab (87.48g × RM 360/g)</span>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)' }}>RM {nf(results.goldNisab)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Silver Nisab (612.36g × RM 4.32/g)</span>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)' }}>RM {nf(results.silverNisab)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0 0' }}>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 700, color: results.zakatDue > 0 ? '#10B981' : 'var(--text-muted)' }}>Zakat Due (2.5%)</span>
|
||||||
|
<span style={{ fontSize: '18px', fontWeight: 700, color: results.zakatDue > 0 ? '#10B981' : 'var(--text-muted)' }}>
|
||||||
|
{results.zakatDue > 0 ? `RM ${nf(results.zakatDue)}` : 'RM 0.00 — Not due'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
|
||||||
|
<button style={{ ...S.btn, background: 'rgba(255,255,255,0.08)', color: 'var(--text-muted)' }} onClick={resetForm}>New Calculation</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'nisab' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{nisabLoading && <div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>Loading…</div>}
|
||||||
|
{nisabError && <ErrBox msg={nisabError} />}
|
||||||
|
{nisabRates && (
|
||||||
|
<>
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.label}>Gold Nisab</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '4px' }}>
|
||||||
|
{nisabRates.goldNisabG}g × MYR {nf(nisabRates.goldPricePerG)}/g
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 700, color: '#F59E0B' }}>RM {nf(nisabRates.goldNisabValue)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={S.card}>
|
||||||
|
<div style={S.label}>Silver Nisab</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: '4px' }}>
|
||||||
|
{nisabRates.silverNisabG}g × MYR {nf(nisabRates.silverPricePerG)}/g
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 700, color: '#94A3B8' }}>RM {nf(nisabRates.silverNisabValue)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...S.card, background: 'rgba(16,185,129,0.06)', border: '1px solid rgba(16,185,129,0.15)' }}>
|
||||||
|
<div style={S.label}>Effective Nisab Threshold</div>
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--text-muted)', marginBottom: '4px' }}>
|
||||||
|
The lower of gold and silver nisab values
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '20px', fontWeight: 700, color: '#10B981' }}>
|
||||||
|
RM {nf(Math.min(nisabRates.goldNisabValue, nisabRates.silverNisabValue))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'records' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text-muted)', fontWeight: 600 }}>{records.length} record{records.length !== 1 ? 's' : ''}</span>
|
||||||
|
{records.length > 0 && (
|
||||||
|
<button onClick={clearRecords} style={{ ...S.btn, background: 'rgba(239,68,68,0.15)', color: '#F87171', padding: '5px 10px', fontSize: '11px' }}>Clear All</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{records.length === 0 ? (
|
||||||
|
<div style={{ ...S.card, textAlign: 'center', padding: '24px 16px', color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||||
|
No zakat calculations yet. Use the calculator to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<div key={i} style={{ ...S.card, padding: '10px 14px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{new Date(r.date).toLocaleDateString('en-MY', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text)', marginTop: '2px' }}>RM {nf(r.netWealth)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>Zakat</div>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 700, color: r.zakatDue > 0 ? '#10B981' : 'var(--text-muted)' }}>RM {nf(r.zakatDue)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppWindow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { FalahApp } from '../types';
|
||||||
|
|
||||||
|
interface AppIconProps {
|
||||||
|
app: FalahApp;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppIcon({ app, onClick }: AppIconProps) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transform: hovered ? 'scale(1.08)' : 'scale(1)',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={app.iconClass}
|
||||||
|
style={{
|
||||||
|
width: '72px',
|
||||||
|
height: '72px',
|
||||||
|
borderRadius: '18px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '32px',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{app.icon}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text)',
|
||||||
|
textShadow: '0 1px 4px rgba(0,0,0,0.8)',
|
||||||
|
opacity: 0.85,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '72px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{app.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useApp } from '../store';
|
||||||
|
|
||||||
|
interface DockItemConfig {
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
gradient: string;
|
||||||
|
onClick: () => void;
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DockItem({
|
||||||
|
item,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
item: DockItemConfig;
|
||||||
|
active?: boolean;
|
||||||
|
}) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '5px', position: 'relative' }}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
{/* Label */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-26px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
opacity: hovered ? 1 : 0,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={item.onClick}
|
||||||
|
style={{
|
||||||
|
width: '52px',
|
||||||
|
height: '52px',
|
||||||
|
borderRadius: '14px',
|
||||||
|
background: item.gradient,
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '24px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transform: hovered ? 'scale(1.35) translateY(-6px)' : 'scale(1) translateY(0)',
|
||||||
|
transition: 'transform 0.18s cubic-bezier(0.34,1.56,0.64,1)',
|
||||||
|
position: 'relative',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.badge && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-4px',
|
||||||
|
right: '-4px',
|
||||||
|
background: 'var(--destructive)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '9px',
|
||||||
|
borderRadius: '99px',
|
||||||
|
padding: '0 4px',
|
||||||
|
minWidth: '16px',
|
||||||
|
height: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: '2px solid var(--bg-deep)',
|
||||||
|
lineHeight: 1,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Active indicator dot */}
|
||||||
|
{active && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-10px',
|
||||||
|
width: '4px',
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--brand)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dock() {
|
||||||
|
const { overlay, closeOverlay, openOverlay, dockAutoHide, toggleDockAutoHide } = useApp();
|
||||||
|
const [dockHidden, setDockHidden] = useState(false);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
|
if (!dockAutoHide) return;
|
||||||
|
const nearBottom = window.innerHeight - e.clientY < 60;
|
||||||
|
setDockHidden(!nearBottom);
|
||||||
|
}, [dockAutoHide]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dockAutoHide) {
|
||||||
|
setDockHidden(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
}, [dockAutoHide, handleMouseMove]);
|
||||||
|
|
||||||
|
const dockItems: DockItemConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'home',
|
||||||
|
icon: '🏠',
|
||||||
|
label: 'Home',
|
||||||
|
gradient: 'linear-gradient(135deg, #10B981, #059669)',
|
||||||
|
onClick: () => closeOverlay(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'istore',
|
||||||
|
icon: '🛍️',
|
||||||
|
label: 'iStore',
|
||||||
|
gradient: 'linear-gradient(135deg, var(--brand), var(--brand-dark))',
|
||||||
|
onClick: () => openOverlay('istore'),
|
||||||
|
badge: '3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
icon: '⚙️',
|
||||||
|
label: 'Settings',
|
||||||
|
gradient: 'linear-gradient(135deg, #475569, #334155)',
|
||||||
|
onClick: () => openOverlay('settings'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'live-usage',
|
||||||
|
icon: '📊',
|
||||||
|
label: 'Live Usage',
|
||||||
|
gradient: 'linear-gradient(135deg, #0284C7, #0369A1)',
|
||||||
|
onClick: () => openOverlay('live-usage'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '14px',
|
||||||
|
left: '50%',
|
||||||
|
transform: dockHidden
|
||||||
|
? 'translateX(-50%) translateY(90px)'
|
||||||
|
: 'translateX(-50%) translateY(0)',
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: '10px',
|
||||||
|
background: 'rgba(0,0,0,0.35)',
|
||||||
|
backdropFilter: 'blur(24px)',
|
||||||
|
WebkitBackdropFilter: 'blur(24px)',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: '22px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
boxShadow: 'var(--shadow-dock)',
|
||||||
|
transition: 'transform 0.25s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dockItems.map(item => (
|
||||||
|
<DockItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
active={
|
||||||
|
(item.id === 'home' && overlay === null) ||
|
||||||
|
(item.id === 'istore' && overlay === 'istore') ||
|
||||||
|
(item.id === 'settings' && overlay === 'settings') ||
|
||||||
|
(item.id === 'live-usage' && overlay === 'live-usage')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '1px',
|
||||||
|
height: '40px',
|
||||||
|
background: 'var(--glass-border)',
|
||||||
|
margin: '0 4px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Widgets */}
|
||||||
|
<DockItem
|
||||||
|
item={{
|
||||||
|
id: 'widgets',
|
||||||
|
icon: '🧩',
|
||||||
|
label: 'Widgets',
|
||||||
|
gradient: 'linear-gradient(135deg, #7C3AED, #6D28D9)',
|
||||||
|
onClick: () => {},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '1px',
|
||||||
|
height: '40px',
|
||||||
|
background: 'var(--glass-border)',
|
||||||
|
margin: '0 4px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Autohide Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleDockAutoHide}
|
||||||
|
title={dockAutoHide ? 'Dock auto-hide: ON' : 'Dock auto-hide: OFF'}
|
||||||
|
style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: dockAutoHide ? 'var(--brand-dim)' : 'transparent',
|
||||||
|
border: `1px solid ${dockAutoHide ? 'rgba(201,168,76,0.3)' : 'var(--glass-border)'}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: dockAutoHide ? 'var(--brand)' : 'var(--text-muted)',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!dockAutoHide) e.currentTarget.style.background = 'var(--glass-hover)'; }}
|
||||||
|
onMouseLeave={e => { if (!dockAutoHide) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
{dockAutoHide ? '👁' : '👁🗨'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
message: string;
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toast({ message, show }: ToastProps) {
|
||||||
|
return (
|
||||||
|
<div className={`toast ${show ? 'show' : ''}`}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
message: string;
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const [state, setState] = useState<ToastState>({ message: '', show: false });
|
||||||
|
|
||||||
|
const showToast = (message: string) => {
|
||||||
|
setState({ message, show: true });
|
||||||
|
setTimeout(() => setState(s => ({ ...s, show: false })), 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { toast: state, showToast };
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const BalanceWidget: React.FC = () => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 110,
|
||||||
|
background: isHovered ? 'var(--glass-hover)' : 'var(--glass)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: '16px 20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
transition: 'background 0.2s, transform 0.15s',
|
||||||
|
transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleRowStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotStyle: React.CSSProperties = {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--brand)',
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleTextStyle: React.CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.8px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridStyle: React.CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cellStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueStyle: React.CSSProperties = {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text)',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const subStyle: React.CSSProperties = {
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={containerStyle}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<div style={titleRowStyle}>
|
||||||
|
<div style={dotStyle} />
|
||||||
|
<span style={titleTextStyle}>FLH BALANCE</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={gridStyle}>
|
||||||
|
<div style={cellStyle}>
|
||||||
|
<span style={valueStyle}>42,500</span>
|
||||||
|
<span style={subStyle}>Available FLH</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={cellStyle}>
|
||||||
|
<span style={{ ...valueStyle, color: 'var(--brand)' }}>+12.4%</span>
|
||||||
|
<span style={subStyle}>7d change</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={cellStyle}>
|
||||||
|
<span style={valueStyle}>3</span>
|
||||||
|
<span style={subStyle}>Active wallets</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={cellStyle}>
|
||||||
|
<span style={{ ...valueStyle, color: 'var(--green)' }}>1.5%</span>
|
||||||
|
<span style={subStyle}>Protocol fee</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BalanceWidget;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const CertsWidget: React.FC = () => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 110,
|
||||||
|
background: isHovered ? 'var(--glass-hover)' : 'var(--glass)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: '16px 20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
transition: 'background 0.2s, transform 0.15s',
|
||||||
|
transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleRowStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotStyle: React.CSSProperties = {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#0284C7',
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleTextStyle: React.CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.8px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueBaseStyle: React.CSSProperties = {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
color: 'var(--text)',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={containerStyle}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<div style={titleRowStyle}>
|
||||||
|
<div style={dotStyle} />
|
||||||
|
<span style={titleTextStyle}>HALAL CERTS</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={valueBaseStyle}>18</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-muted)' }}>Active</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ ...valueBaseStyle, color: 'var(--green)' }}>3</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-muted)' }}>Expiring</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ ...valueBaseStyle, color: 'var(--brand)' }}>142</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-muted)' }}>Products</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CertsWidget;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const ContractsWidget: React.FC = () => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 110,
|
||||||
|
background: isHovered ? 'var(--glass-hover)' : 'var(--glass)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: '16px 20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
transition: 'background 0.2s, transform 0.15s',
|
||||||
|
transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleRowStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotStyle: React.CSSProperties = {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#F59E0B',
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleTextStyle: React.CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.8px',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={containerStyle}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<div style={titleRowStyle}>
|
||||||
|
<div style={dotStyle} />
|
||||||
|
<span style={titleTextStyle}>RAMZ CONTRACTS</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-1px', marginBottom: 4, lineHeight: 1 }}>24</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Active contracts</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-muted)', marginTop: 6 }}>
|
||||||
|
<span>Shariah compliance</span>
|
||||||
|
<span>96%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 3, background: 'rgba(255,255,255,0.1)', borderRadius: 99, overflow: 'hidden', marginTop: 10 }}>
|
||||||
|
<div style={{ width: '96%', height: '100%', background: 'linear-gradient(90deg, var(--brand), var(--brand-light))', borderRadius: 99 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContractsWidget;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { FalahApp } from '../types'
|
||||||
|
|
||||||
|
export const CORE_APPS: FalahApp[] = [
|
||||||
|
{ id: 'ummahid', name: 'Ummah ID', icon: '🪪', iconClass: 'icon-ummahid', tagline: 'Zero-knowledge identity', category: 'Identity', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'wallet', name: 'Wallet', icon: '💎', iconClass: 'icon-wallet', tagline: 'FLH token & transfers', category: 'Finance', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'zakat-vault', name: 'Zakat Vault', icon: '🕌', iconClass: 'icon-zakat', tagline: 'Automated zakat calculation & distribution',category: 'Finance', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'waqf-chain', name: 'Waqf Chain', icon: '🔗', iconClass: 'icon-waqf', tagline: 'Endowment asset tokenization', category: 'Finance', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'qurban', name: 'Qurban', icon: '🐑', iconClass: 'icon-qurban', tagline: 'Eid sacrifice booking & distribution', category: 'Worship', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'sadaqah-stream', name: 'Sadaqah Stream',icon: '💧', iconClass: 'icon-sadaqah', tagline: 'Recurring micro-charity platform', category: 'Charity', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'infaq', name: 'Infaq', icon: '🤝', iconClass: 'icon-infaq', tagline: 'Community crowdfunding for good causes', category: 'Charity', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'community', name: 'Community', icon: '🕌', iconClass: 'icon-community', tagline: 'Mosque, school & guild tools', category: 'Community', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'chat', name: 'Chat', icon: '💬', iconClass: 'icon-chat', tagline: 'Real-time community messaging', category: 'Community', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'prayer', name: 'Prayer', icon: '🌙', iconClass: 'icon-prayer', tagline: 'Prayer times & Islamic calendar', category: 'Islamic', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'ramz', name: 'RAMZ', icon: '📜', iconClass: 'icon-ramz', tagline: 'Shariah contract engine', category: 'Contracts', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'certs', name: 'Halal Certs', icon: '✅', iconClass: 'icon-certs', tagline: 'HCT certificate registry', category: 'Compliance',ramzVerified: true, installed: true },
|
||||||
|
{ id: 'mocknet', name: 'Mock-Net', icon: '🧪', iconClass: 'icon-mocknet', tagline: 'Sandbox & chaos testing', category: 'Dev Tools', ramzVerified: false, installed: true },
|
||||||
|
{ id: 'istore', name: 'iStore', icon: '🛍️', iconClass: 'icon-istore', tagline: 'Halal-verified apps', category: 'Store', ramzVerified: true, installed: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const STORE_APPS: FalahApp[] = [
|
||||||
|
{ id: 'zakat-vault', name: 'Zakat Vault', icon: '🕌', iconClass: 'icon-zakat', tagline: 'Automated zakat calculation & distribution', category: 'Finance', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'faraid-calc', name: 'Faraid Calculator', icon: '⚖️', iconClass: 'icon-faraid', tagline: 'Islamic inheritance law calculator', category: 'Legal', ramzVerified: true, installed: false },
|
||||||
|
{ id: 'waqf-chain', name: 'Waqf Chain', icon: '🔗', iconClass: 'icon-waqf', tagline: 'Endowment asset tokenization', category: 'Finance', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'sadaqah-stream', name: 'Sadaqah Stream', icon: '💧', iconClass: 'icon-sadaqah', tagline: 'Recurring micro-charity platform', category: 'Charity', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'qurban', name: 'Qurban', icon: '🐑', iconClass: 'icon-qurban', tagline: 'Eid sacrifice booking & distribution', category: 'Worship', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'infaq', name: 'Infaq', icon: '🤝', iconClass: 'icon-infaq', tagline: 'Community crowdfunding for good causes', category: 'Charity', ramzVerified: true, installed: true },
|
||||||
|
{ id: 'halal-trade', name: 'Halal Trade Desk', icon: '📊', iconClass: 'icon-ramz', tagline: 'Shariah-compliant commodity trading', category: 'Finance', ramzVerified: true, installed: false },
|
||||||
|
{ id: 'sukuk-issuance', name: 'Sukuk Issuance', icon: '🏦', iconClass: 'icon-certs', tagline: 'Islamic bond tokenization suite', category: 'Finance', ramzVerified: true, installed: false },
|
||||||
|
{ id: 'takaful-pool', name: 'Takaful Pool', icon: '🛡️', iconClass: 'icon-ummahid', tagline: 'Mutual insurance cooperative', category: 'Insurance', ramzVerified: true, installed: false },
|
||||||
|
{ id: 'qibla-pay', name: 'QiblaPay', icon: '🧭', iconClass: 'icon-wallet', tagline: 'Halal point-of-sale terminal', category: 'Payments', ramzVerified: true, installed: false },
|
||||||
|
{ id: 'dinar-bridge', name: 'Dinar Bridge', icon: '🌐', iconClass: 'icon-mocknet', tagline: 'Cross-chain gold-backed settlement', category: 'DeFi', ramzVerified: false, installed: false },
|
||||||
|
{ id: 'musharakah-fund', name: 'Musharakah Fund', icon: '🤝', iconClass: 'icon-ramz', tagline: 'Joint-venture investment pools', category: 'Investment', ramzVerified: true, installed: false },
|
||||||
|
{ id: 'haram-scanner', name: 'Haram Scanner', icon: '🔍', iconClass: 'icon-certs', tagline: 'Product ingredient screening API', category: 'Compliance', ramzVerified: true, installed: false },
|
||||||
|
{ id: 'barakah-savings', name: 'Barakah Savings', icon: '🌱', iconClass: 'icon-wallet', tagline: 'Profit-sharing savings accounts', category: 'Finance', ramzVerified: true, installed: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CATEGORIES = ['All', 'Finance', 'Contracts', 'Compliance', 'Identity', 'Dev Tools', 'Charity', 'Worship', 'Investment', 'Insurance', 'Payments', 'DeFi', 'Legal']
|
||||||
|
|
||||||
|
export const FEATURED_APPS = ['zakat-vault', 'waqf-chain', 'sadaqah-stream']
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { WallpaperOption } from '../types'
|
||||||
|
|
||||||
|
export const WALLPAPERS: WallpaperOption[] = [
|
||||||
|
{ id: 'default', label: 'Desert Gold', swatchClass: 'swatch-default' },
|
||||||
|
{ id: 'teal', label: 'Teal Sea', swatchClass: 'swatch-teal' },
|
||||||
|
{ id: 'gold', label: 'Rich Gold', swatchClass: 'swatch-gold' },
|
||||||
|
{ id: 'sapphire', label: 'Sapphire', swatchClass: 'swatch-sapphire' },
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost'
|
||||||
|
|
||||||
|
export const API = {
|
||||||
|
ummahid: `http://${host}:3001`,
|
||||||
|
wallet: `http://${host}:3002`,
|
||||||
|
ramz: `http://${host}:3003`,
|
||||||
|
mocknet: `http://${host}:3004`,
|
||||||
|
falahd: `http://${host}:3006`,
|
||||||
|
community: `http://${host}:3007`,
|
||||||
|
prayer: `http://${host}:3008`,
|
||||||
|
chat: `http://${host}:3009`,
|
||||||
|
zakat: `http://${host}:3011`,
|
||||||
|
waqf: `http://${host}:3012`,
|
||||||
|
qurban: `http://${host}:3016`,
|
||||||
|
sadaqah: `http://${host}:3014`,
|
||||||
|
infaq: `http://${host}:3015`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T>(url: string, token?: string | null, opts?: RequestInit): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...(opts?.headers as Record<string, string>) }
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
const res = await fetch(url, { ...opts, headers })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new Error((err as { error?: string }).error || res.statusText)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useClock() {
|
||||||
|
const [now, setNow] = useState(new Date())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(new Date()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const time = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
const date = now.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
|
||||||
|
|
||||||
|
return { time, date }
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=JetBrains+Mono:wght@300;400;500&display=swap');
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── Design tokens ─────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--brand: #C9A84C;
|
||||||
|
--brand-dark: #B8963A;
|
||||||
|
--brand-light: #E8C97A;
|
||||||
|
--brand-dim: rgba(201,168,76,0.15);
|
||||||
|
--green: #00C48C;
|
||||||
|
--green-light: #00E09E;
|
||||||
|
--destructive: #EF4444;
|
||||||
|
--bg-deep: #07090C;
|
||||||
|
--bg-surface: #0C1A2E;
|
||||||
|
--bg-elevated: #112236;
|
||||||
|
--glass: rgba(255,255,255,0.06);
|
||||||
|
--glass-hover: rgba(255,255,255,0.09);
|
||||||
|
--glass-border: rgba(255,255,255,0.10);
|
||||||
|
--glass-strong: rgba(255,255,255,0.12);
|
||||||
|
--text: rgba(255,255,255,0.90);
|
||||||
|
--text-muted: rgba(255,255,255,0.50);
|
||||||
|
--text-dim: rgba(255,255,255,0.30);
|
||||||
|
--shadow-dock: 0 8px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.08);
|
||||||
|
--shadow-card: 0 4px 24px rgba(0,0,0,0.4), 0 1px 4px rgba(0,0,0,0.3);
|
||||||
|
--shadow-modal: 0 24px 80px rgba(0,0,0,0.7), 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ──────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body, #root {
|
||||||
|
width: 100%; height: 100%; overflow: hidden;
|
||||||
|
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
button { border: none; cursor: pointer; font-family: inherit; background: none; }
|
||||||
|
input { font-family: inherit; border: none; outline: none; }
|
||||||
|
|
||||||
|
/* ── Wallpaper backgrounds ────────────────────────────── */
|
||||||
|
.wallpaper {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 15% 40%, rgba(201,168,76,0.14) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 85% 20%, rgba(184,150,58,0.10) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 50% 70% at 50% 90%, rgba(201,168,76,0.08) 0%, transparent 50%),
|
||||||
|
#07090C;
|
||||||
|
}
|
||||||
|
.wallpaper::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cg fill='none' stroke='rgba(201,168,76,0.06)' stroke-width='0.7'%3E%3Crect x='30' y='15' width='60' height='90' transform='rotate(45 60 60)'/%3E%3Crect x='15' y='30' width='90' height='60'/%3E%3Ccircle cx='60' cy='60' r='25'/%3E%3Ccircle cx='60' cy='60' r='42'/%3E%3Cline x1='60' y1='0' x2='60' y2='120'/%3E%3Cline x1='0' y1='60' x2='120' y2='60'/%3E%3Cline x1='0' y1='0' x2='120' y2='120'/%3E%3Cline x1='120' y1='0' x2='0' y2='120'/%3E%3C/g%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
.wallpaper.wp-teal {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 15% 40%, rgba(0,196,140,0.18) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 85% 20%, rgba(6,182,212,0.12) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 50% 70% at 50% 90%, rgba(0,196,140,0.10) 0%, transparent 50%),
|
||||||
|
#07090C;
|
||||||
|
--brand: #00C48C; --brand-light: #00E09E; --brand-dark: #00A87A; --brand-dim: rgba(0,196,140,0.15);
|
||||||
|
}
|
||||||
|
.wallpaper.wp-gold {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 15% 40%, rgba(201,168,76,0.18) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 85% 20%, rgba(232,201,122,0.12) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 50% 70% at 50% 90%, rgba(201,168,76,0.10) 0%, transparent 50%),
|
||||||
|
#0A0B0D;
|
||||||
|
--brand: #C9A84C; --brand-light: #E8C97A; --brand-dark: #B8963A; --brand-dim: rgba(201,168,76,0.15);
|
||||||
|
}
|
||||||
|
.wallpaper.wp-sapphire {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 15% 40%, rgba(99,102,241,0.14) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 85% 20%, rgba(0,196,140,0.08) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 50% 70% at 50% 90%, rgba(99,102,241,0.10) 0%, transparent 50%),
|
||||||
|
#07091A;
|
||||||
|
--brand: #6366F1; --brand-light: #A5B4FC; --brand-dark: #4F46E5; --brand-dim: rgba(99,102,241,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Wallpaper picker swatches ───────────────────────── */
|
||||||
|
.swatch-default { background: linear-gradient(135deg, #07090C 50%, #C9A84C 150%); }
|
||||||
|
.swatch-teal { background: linear-gradient(135deg, #07090C 50%, #00C48C 150%); }
|
||||||
|
.swatch-gold { background: linear-gradient(135deg, #0A0B0D 50%, #C9A84C 150%); }
|
||||||
|
.swatch-sapphire { background: linear-gradient(135deg, #07091A 50%, #6366F1 150%); }
|
||||||
|
|
||||||
|
/* ── App icon gradients ──────────────────────────────── */
|
||||||
|
.icon-ummahid { background: linear-gradient(145deg, #7C3AED, #5B21B6); }
|
||||||
|
.icon-wallet { background: linear-gradient(145deg, #059669, #065F46); }
|
||||||
|
.icon-ramz { background: linear-gradient(145deg, #D97706, #92400E); }
|
||||||
|
.icon-certs { background: linear-gradient(145deg, #0284C7, #075985); }
|
||||||
|
.icon-mocknet { background: linear-gradient(145deg, #475569, #1E293B); }
|
||||||
|
.icon-istore { background: linear-gradient(145deg, var(--brand), var(--brand-dark)); }
|
||||||
|
.icon-community { background: linear-gradient(145deg, #15803D, #052E16); }
|
||||||
|
.icon-prayer { background: linear-gradient(145deg, #1D4ED8, #1E3A5F); }
|
||||||
|
.icon-chat { background: linear-gradient(145deg, #7C3AED, #3B0764); }
|
||||||
|
.icon-zakat { background: linear-gradient(145deg, #DC2626, #991B1B); }
|
||||||
|
.icon-faraid { background: linear-gradient(145deg, #7C3AED, #4C1D95); }
|
||||||
|
.icon-waqf { background: linear-gradient(145deg, #0284C7, #0C4A6E); }
|
||||||
|
.icon-qurban { background: linear-gradient(145deg, #D97706, #78350F); }
|
||||||
|
.icon-sadaqah { background: linear-gradient(145deg, #0D9488, #115E59); }
|
||||||
|
.icon-infaq { background: linear-gradient(145deg, #8B5CF6, #5B21B6); }
|
||||||
|
|
||||||
|
/* ── Font utilities ──────────────────────────────────── */
|
||||||
|
.font-heading { font-family: 'Cinzel', 'Georgia', serif; }
|
||||||
|
.font-mono { font-family: 'JetBrains Mono', 'Courier New', monospace; }
|
||||||
|
|
||||||
|
/* ── Glass utilities ─────────────────────────────────── */
|
||||||
|
.glass { background: var(--glass); border: 1px solid var(--glass-border); }
|
||||||
|
.glass:hover { background: var(--glass-hover); }
|
||||||
|
.glass-strong { background: var(--glass-strong); border: 1px solid var(--glass-border); }
|
||||||
|
|
||||||
|
/* ── Toggle switch ───────────────────────────────────── */
|
||||||
|
.toggle {
|
||||||
|
width: 40px; height: 22px; border-radius: 99px;
|
||||||
|
background: var(--glass-strong); position: relative;
|
||||||
|
transition: background 0.2s; cursor: pointer; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle.on { background: var(--brand); }
|
||||||
|
.toggle-knob {
|
||||||
|
position: absolute; top: 3px; left: 3px;
|
||||||
|
width: 16px; height: 16px; border-radius: 99px;
|
||||||
|
background: white; transition: left 0.2s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.toggle.on .toggle-knob { left: 21px; }
|
||||||
|
|
||||||
|
/* ── Buttons ─────────────────────────────────────────── */
|
||||||
|
.btn-primary {
|
||||||
|
padding: 12px 24px; font-size: 15px; font-weight: 600;
|
||||||
|
color: white; background: var(--brand); border-radius: 8px;
|
||||||
|
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
|
||||||
|
box-shadow: 0 4px 16px rgba(201,168,76,0.3);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--brand-light); }
|
||||||
|
.btn-primary:active { transform: scale(0.98); }
|
||||||
|
.btn-ghost {
|
||||||
|
padding: 8px 14px; font-size: 13px; font-weight: 500;
|
||||||
|
color: var(--text-muted); border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px; background: var(--glass); transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--glass-hover); color: var(--text); }
|
||||||
|
.btn-install {
|
||||||
|
padding: 6px 16px; font-size: 12px; font-weight: 600;
|
||||||
|
background: var(--brand-dim); color: var(--brand);
|
||||||
|
border: 1px solid rgba(201,168,76,0.25); border-radius: 99px;
|
||||||
|
transition: all 0.15s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-install:hover { background: var(--brand); color: white; }
|
||||||
|
.btn-installed { background: var(--glass); color: var(--text-muted); border-color: var(--glass-border); }
|
||||||
|
.btn-close {
|
||||||
|
width: 32px; height: 32px; border-radius: 99px; background: var(--glass);
|
||||||
|
color: var(--text-muted); font-size: 18px; display: flex; align-items: center;
|
||||||
|
justify-content: center; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-close:hover { background: var(--glass-hover); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Category chip ───────────────────────────────────── */
|
||||||
|
.cat-chip {
|
||||||
|
padding: 7px 16px; border-radius: 99px; font-size: 13px; font-weight: 500;
|
||||||
|
cursor: pointer; white-space: nowrap; transition: all 0.15s;
|
||||||
|
background: var(--glass); border: 1px solid var(--glass-border); color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.cat-chip:hover { background: var(--glass-hover); color: var(--text); }
|
||||||
|
.cat-chip.active { background: var(--brand); border-color: var(--brand); color: white; box-shadow: 0 2px 12px rgba(201,168,76,0.35); }
|
||||||
|
|
||||||
|
/* ── RAMZ badge ──────────────────────────────────────── */
|
||||||
|
.ramz-badge { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--brand); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* ── Toast ───────────────────────────────────────────── */
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 90px; left: 50%; transform: translateX(-50%) translateY(20px);
|
||||||
|
background: rgba(12,26,46,0.95); backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid var(--glass-border); border-radius: 99px;
|
||||||
|
padding: 10px 20px; font-size: 13px; color: var(--text);
|
||||||
|
box-shadow: var(--shadow-card); z-index: 500;
|
||||||
|
opacity: 0; transition: opacity 0.25s, transform 0.25s;
|
||||||
|
white-space: nowrap; pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
|
||||||
|
/* ── Scrollbar ───────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--glass-border); border-radius: 99px; }
|
||||||
|
* { scrollbar-width: thin; scrollbar-color: var(--glass-border) transparent; }
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
import { AppProvider } from './store'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<AppProvider>
|
||||||
|
<App />
|
||||||
|
</AppProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
import { STORE_APPS, FEATURED_APPS, CATEGORIES } from '../data/apps'
|
||||||
|
import type { FalahApp } from '../types'
|
||||||
|
|
||||||
|
const FEATURED_APP_DATA: FalahApp[] = STORE_APPS.filter(a =>
|
||||||
|
FEATURED_APPS.includes(a.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const FEATURED_GRADIENTS: Record<string, string> = {
|
||||||
|
'zakat-vault': 'linear-gradient(135deg, #DC2626, #7F1D1D)',
|
||||||
|
'faraid-calc': 'linear-gradient(135deg, #7C3AED, #3B0764)',
|
||||||
|
'waqf-chain': 'linear-gradient(135deg, #0284C7, #0C4A6E)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IStore() {
|
||||||
|
const { overlay, closeOverlay } = useApp()
|
||||||
|
const isOpen = overlay === 'istore'
|
||||||
|
|
||||||
|
const [activeCategory, setActiveCategory] = useState('All')
|
||||||
|
const [installed, setInstalled] = useState<Set<string>>(new Set())
|
||||||
|
const [progress, setProgress] = useState<Record<string, number>>({})
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const timerRefs = useRef<Record<string, ReturnType<typeof setInterval>>>({})
|
||||||
|
|
||||||
|
const filteredApps = STORE_APPS.filter(app => {
|
||||||
|
const matchesCat = activeCategory === 'All' || app.category === activeCategory
|
||||||
|
const matchesSearch =
|
||||||
|
searchQuery === '' ||
|
||||||
|
app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
app.tagline.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
return matchesCat && matchesSearch
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleInstall(appId: string) {
|
||||||
|
if (installed.has(appId) || progress[appId] !== undefined) return
|
||||||
|
setProgress(prev => ({ ...prev, [appId]: 0 }))
|
||||||
|
let pct = 0
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
pct += 100 / 15
|
||||||
|
if (pct >= 100) {
|
||||||
|
clearInterval(interval)
|
||||||
|
delete timerRefs.current[appId]
|
||||||
|
setProgress(prev => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[appId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setInstalled(prev => new Set(prev).add(appId))
|
||||||
|
} else {
|
||||||
|
setProgress(prev => ({ ...prev, [appId]: Math.min(Math.round(pct), 99) }))
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
timerRefs.current[appId] = interval
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Object.values(timerRefs.current).forEach(clearInterval)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(6,13,24,0.92)',
|
||||||
|
backdropFilter: 'blur(40px)',
|
||||||
|
zIndex: 50,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
pointerEvents: isOpen ? 'all' : 'none',
|
||||||
|
transition: 'transform 0.45s cubic-bezier(0.32,0.72,0,1), opacity 0.35s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '24px 32px 16px',
|
||||||
|
borderBottom: '1px solid var(--glass-border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: 'linear-gradient(145deg, var(--brand), var(--brand-dark))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '16px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🛍️
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 700, flexShrink: 0 }}>iStore</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-dim)',
|
||||||
|
color: 'var(--brand)',
|
||||||
|
border: '1px solid var(--brand-dim)',
|
||||||
|
borderRadius: '99px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
RAMZ Verified
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search halal apps..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
maxWidth: '360px',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
background: 'var(--glass)',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: '99px',
|
||||||
|
padding: '9px 16px',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button className="btn-close" onClick={closeOverlay} style={{ flexShrink: 0 }}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category chips */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '14px 32px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CATEGORIES.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
className={`cat-chip${activeCategory === cat ? ' active' : ''}`}
|
||||||
|
onClick={() => setActiveCategory(cat)}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '24px 32px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Shariah Compliance Banner */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(16,185,129,0.1), rgba(5,150,105,0.06))',
|
||||||
|
border: '1px solid rgba(16,185,129,0.2)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '16px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '28px', flexShrink: 0 }}>🕌</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3 style={{ margin: '0 0 4px', fontSize: '14px', fontWeight: 600 }}>
|
||||||
|
RAMZ Pre-Verified Apps
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||||
|
Every app is screened by the RAMZ Shariah compliance engine before listing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--brand-dim)',
|
||||||
|
color: 'var(--brand)',
|
||||||
|
borderRadius: '99px',
|
||||||
|
padding: '5px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
7 Rules Active
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured section — only when showing All and no search filter */}
|
||||||
|
{activeCategory === 'All' && searchQuery === '' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '10px', marginBottom: '14px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 700 }}>Featured</h3>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '12px' }}>Halal-first</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FEATURED_APP_DATA.map(app => (
|
||||||
|
<FeaturedCard key={app.id} app={app} gradient={FEATURED_GRADIENTS[app.id] ?? ''} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Apps grid */}
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: '10px', marginBottom: '14px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 700 }}>
|
||||||
|
{activeCategory === 'All' ? 'All Apps' : activeCategory}
|
||||||
|
</h3>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||||
|
{filteredApps.length} apps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredApps.map(app => (
|
||||||
|
<AppRow
|
||||||
|
key={app.id}
|
||||||
|
app={app}
|
||||||
|
isInstalled={installed.has(app.id)}
|
||||||
|
installProgress={progress[app.id]}
|
||||||
|
onInstall={() => handleInstall(app.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredApps.length === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: '40px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No apps found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeaturedCard({ app, gradient }: { app: FalahApp; gradient: string }) {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
borderRadius: '20px',
|
||||||
|
minHeight: '140px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
padding: '20px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
background: gradient,
|
||||||
|
transform: hovered ? 'translateY(-3px)' : 'translateY(0)',
|
||||||
|
boxShadow: hovered
|
||||||
|
? '0 16px 40px rgba(0,0,0,0.5), 0 4px 12px rgba(0,0,0,0.3)'
|
||||||
|
: '0 4px 16px rgba(0,0,0,0.3)',
|
||||||
|
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* RAMZ badge top-right */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12px',
|
||||||
|
right: '12px',
|
||||||
|
background: 'rgba(16,185,129,0.25)',
|
||||||
|
color: 'var(--brand)',
|
||||||
|
borderRadius: '99px',
|
||||||
|
padding: '3px 8px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
RAMZ ✓
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '14px',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '22px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{app.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: 700, color: 'white', margin: '0 0 4px' }}>
|
||||||
|
{app.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'white', opacity: 0.75 }}>{app.tagline}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRow({
|
||||||
|
app,
|
||||||
|
isInstalled,
|
||||||
|
installProgress,
|
||||||
|
onInstall,
|
||||||
|
}: {
|
||||||
|
app: FalahApp
|
||||||
|
isInstalled: boolean
|
||||||
|
installProgress: number | undefined
|
||||||
|
onInstall: () => void
|
||||||
|
}) {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '14px',
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: hovered ? 'var(--glass)' : 'transparent',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* App icon */}
|
||||||
|
<div
|
||||||
|
className={app.iconClass}
|
||||||
|
style={{
|
||||||
|
width: '52px',
|
||||||
|
height: '52px',
|
||||||
|
borderRadius: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '24px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{app.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{app.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{app.tagline}
|
||||||
|
</div>
|
||||||
|
{app.ramzVerified && (
|
||||||
|
<div className="ramz-badge">
|
||||||
|
<span>✓</span>
|
||||||
|
<span>RAMZ Verified</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Install button */}
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
{installProgress !== undefined ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '3px',
|
||||||
|
borderRadius: '99px',
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${installProgress}%`,
|
||||||
|
background: 'var(--brand)',
|
||||||
|
borderRadius: '99px',
|
||||||
|
transition: 'width 0.1s linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '10px', color: 'var(--brand)' }}>{installProgress}%</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`btn-install${isInstalled ? ' btn-installed' : ''}`}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onInstall()
|
||||||
|
}}
|
||||||
|
disabled={isInstalled}
|
||||||
|
>
|
||||||
|
{isInstalled ? 'Installed' : 'Install'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
interface UsageCardProps {
|
||||||
|
label: string
|
||||||
|
pct: number
|
||||||
|
color: string
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageCard({ label, pct, color, detail }: UsageCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{ borderRadius: '14px', padding: '16px 18px' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.8px',
|
||||||
|
marginBottom: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: 800,
|
||||||
|
color,
|
||||||
|
letterSpacing: '-1px',
|
||||||
|
lineHeight: 1,
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pct}%
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '4px',
|
||||||
|
borderRadius: '99px',
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
marginBottom: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${pct}%`,
|
||||||
|
background: color,
|
||||||
|
borderRadius: '99px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{detail}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICES = [
|
||||||
|
{ icon: '🪪', name: 'Ummah ID', pct: 12, color: '#7C3AED' },
|
||||||
|
{ icon: '💎', name: 'Wallet', pct: 28, color: '#10B981' },
|
||||||
|
{ icon: '📜', name: 'RAMZ', pct: 15, color: '#D97706' },
|
||||||
|
{ icon: '🧪', name: 'Mock-Net', pct: 8, color: '#475569' },
|
||||||
|
{ icon: '🔀', name: 'API Gateway', pct: 19, color: '#0284C7' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function LiveUsage() {
|
||||||
|
const { overlay, closeOverlay } = useApp()
|
||||||
|
const isOpen = overlay === 'live-usage'
|
||||||
|
|
||||||
|
const [hoveredService, setHoveredService] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
zIndex: 200,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
pointerEvents: isOpen ? 'all' : 'none',
|
||||||
|
transition: 'opacity 0.25s ease',
|
||||||
|
}}
|
||||||
|
onClick={closeOverlay}
|
||||||
|
>
|
||||||
|
{/* Modal box */}
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: '680px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
background: '#0C1A2E',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: '28px',
|
||||||
|
boxShadow: 'var(--shadow-modal)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transform: isOpen ? 'scale(1) translateY(0)' : 'scale(0.95) translateY(8px)',
|
||||||
|
transition: 'transform 0.25s cubic-bezier(0.34,1.56,0.64,1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Modal header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '20px 24px',
|
||||||
|
borderBottom: '1px solid var(--glass-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 700, flex: 1 }}>
|
||||||
|
📊 Live System Usage
|
||||||
|
</h3>
|
||||||
|
<button className="btn-close" onClick={closeOverlay}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '20px 24px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Usage cards row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UsageCard
|
||||||
|
label="CPU"
|
||||||
|
pct={42}
|
||||||
|
color="#10B981"
|
||||||
|
detail="4 cores · 1.2 GHz load"
|
||||||
|
/>
|
||||||
|
<UsageCard
|
||||||
|
label="Memory"
|
||||||
|
pct={58}
|
||||||
|
color="#F59E0B"
|
||||||
|
detail="3.2 GB / 5.5 GB used"
|
||||||
|
/>
|
||||||
|
<UsageCard
|
||||||
|
label="Disk"
|
||||||
|
pct={23}
|
||||||
|
color="#60A5FA"
|
||||||
|
detail="18 GB / 80 GB used"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-service usage */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text)',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Per-Service Usage
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
|
{SERVICES.map(service => (
|
||||||
|
<div
|
||||||
|
key={service.name}
|
||||||
|
onMouseEnter={() => setHoveredService(service.name)}
|
||||||
|
onMouseLeave={() => setHoveredService(null)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '8px 4px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: hoveredService === service.name ? 'var(--glass)' : 'transparent',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Service icon */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: service.color,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '12px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{service.icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service name */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{service.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Mini bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '80px',
|
||||||
|
height: '3px',
|
||||||
|
borderRadius: '99px',
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${service.pct}%`,
|
||||||
|
background: 'var(--brand)',
|
||||||
|
borderRadius: '99px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Percentage */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
width: '32px',
|
||||||
|
textAlign: 'right',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{service.pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
import { WALLPAPERS } from '../data/wallpapers'
|
||||||
|
import type { Wallpaper } from '../types'
|
||||||
|
|
||||||
|
interface ToggleRowProps {
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
defaultOn: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
chevron?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleRow({ icon, title, description, defaultOn, disabled, chevron }: ToggleRowProps) {
|
||||||
|
const [on, setOn] = useState(defaultOn)
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '14px',
|
||||||
|
padding: '15px 20px',
|
||||||
|
background: hovered ? 'var(--glass-hover)' : 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
|
}}
|
||||||
|
onClick={() => { if (!disabled && !chevron) setOn(v => !v) }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'var(--brand-dim)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '16px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: 600, color: 'var(--text)' }}>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: '12px', color: 'var(--text-muted)' }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chevron ? (
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: '18px' }}>›</span>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`toggle${on ? ' on' : ''}`}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<div className="toggle-knob" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const { overlay, closeOverlay, wallpaper, setWallpaper } = useApp()
|
||||||
|
const isOpen = overlay === 'settings'
|
||||||
|
|
||||||
|
const wallpaperGradients: Record<Wallpaper, string> = {
|
||||||
|
default: 'radial-gradient(ellipse 80% 60% at 15% 40%, rgba(16,185,129,0.25) 0%, transparent 60%), radial-gradient(ellipse 60% 50% at 85% 20%, rgba(5,150,105,0.18) 0%, transparent 55%), #060D18',
|
||||||
|
teal: 'radial-gradient(ellipse 80% 60% at 15% 40%, rgba(16,185,129,0.3) 0%, transparent 60%), radial-gradient(ellipse 60% 50% at 85% 20%, rgba(6,182,212,0.2) 0%, transparent 55%), #060D18',
|
||||||
|
gold: 'radial-gradient(ellipse 80% 60% at 15% 40%, rgba(245,158,11,0.22) 0%, transparent 60%), radial-gradient(ellipse 60% 50% at 85% 20%, rgba(16,185,129,0.16) 0%, transparent 55%), #090C0E',
|
||||||
|
sapphire: 'radial-gradient(ellipse 80% 60% at 15% 40%, rgba(99,102,241,0.24) 0%, transparent 60%), radial-gradient(ellipse 60% 50% at 85% 20%, rgba(16,185,129,0.12) 0%, transparent 55%), #07091A',
|
||||||
|
}
|
||||||
|
|
||||||
|
const miniIconColors = ['#10B981', '#7C3AED', '#D97706', '#0284C7', '#475569', '#10B981']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(6,13,24,0.95)',
|
||||||
|
backdropFilter: 'blur(40px)',
|
||||||
|
zIndex: 50,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
pointerEvents: isOpen ? 'all' : 'none',
|
||||||
|
transition: 'transform 0.4s cubic-bezier(0.32,0.72,0,1), opacity 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '24px 32px 20px',
|
||||||
|
borderBottom: '1px solid var(--glass-border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="btn-ghost" onClick={closeOverlay} style={{ fontSize: '15px' }}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 700 }}>Settings</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '28px 32px',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '320px 1fr',
|
||||||
|
gap: '28px',
|
||||||
|
alignContent: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Left column ── */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
|
||||||
|
{/* Desktop Preview */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '16/10',
|
||||||
|
borderRadius: '20px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
position: 'relative',
|
||||||
|
background: wallpaperGradients[wallpaper],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mini app icons */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{miniIconColors.map((color, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '5px',
|
||||||
|
background: color,
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini dock */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '8px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '4px 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{['#10B981', '#7C3AED', '#D97706', '#0284C7'].map((color, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '3px',
|
||||||
|
background: color,
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wallpaper picker */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: '0.8px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Wallpaper
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
{WALLPAPERS.map(wp => (
|
||||||
|
<button
|
||||||
|
key={wp.id}
|
||||||
|
className={wp.swatchClass}
|
||||||
|
onClick={() => setWallpaper(wp.id)}
|
||||||
|
title={wp.label}
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '32px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: wallpaper === wp.id
|
||||||
|
? '2px solid var(--brand)'
|
||||||
|
: '2px solid transparent',
|
||||||
|
boxShadow: wallpaper === wp.id
|
||||||
|
? '0 0 0 3px var(--brand-dim), 0 0 12px rgba(16,185,129,0.4)'
|
||||||
|
: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right column ── */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
|
||||||
|
{/* User card */}
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '20px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: '99px',
|
||||||
|
background: 'linear-gradient(145deg, var(--brand), var(--brand-dark))',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
O
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: 600, color: 'var(--text)' }}>
|
||||||
|
Falah Operator
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||||
|
REGULATOR · Community Edition
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
|
||||||
|
<button className="btn-ghost" style={{ fontSize: '12px' }}>
|
||||||
|
Node Info
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.25)',
|
||||||
|
background: 'rgba(239,68,68,0.08)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(239,68,68,0.15)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(239,68,68,0.08)')}
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Metrics */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||||
|
gap: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: 'CPU', value: '42%', sub: '0.8 load avg', pct: 42, color: 'var(--brand)' },
|
||||||
|
{ label: 'Memory', value: '58%', sub: '3.2 / 5.5 GB', pct: 58, color: 'var(--green)' },
|
||||||
|
{ label: 'Disk', value: '23%', sub: '18 / 80 GB', pct: 23, color: 'var(--brand)' },
|
||||||
|
{ label: 'Temp', value: '61°C', sub: 'Normal range', pct: 61, color: '#60A5FA' },
|
||||||
|
].map(metric => (
|
||||||
|
<div
|
||||||
|
key={metric.label}
|
||||||
|
className="glass"
|
||||||
|
style={{ borderRadius: '14px', padding: '14px 16px' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.8px',
|
||||||
|
marginBottom: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metric.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text)',
|
||||||
|
marginBottom: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metric.value}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
borderRadius: '99px',
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
marginBottom: '6px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${metric.pct}%`,
|
||||||
|
background: metric.color,
|
||||||
|
borderRadius: '99px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>{metric.sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings list */}
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{ borderRadius: '20px', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ icon: '🔔', title: 'Notifications', description: 'Alert on Shariah violations', defaultOn: true, disabled: false, chevron: false },
|
||||||
|
{ icon: '🔒', title: 'Auto-Lock', description: 'Lock after 10 min inactivity', defaultOn: true, disabled: false, chevron: false },
|
||||||
|
{ icon: '🌙', title: 'Dark Mode', description: 'Always on for Falah OS', defaultOn: true, disabled: true, chevron: false },
|
||||||
|
{ icon: '📡', title: 'Telemetry', description: 'Anonymous usage stats', defaultOn: false, disabled: false, chevron: false },
|
||||||
|
{ icon: '📦', title: 'Updates', description: 'Check for app updates', defaultOn: false, disabled: false, chevron: true },
|
||||||
|
].map((row, i, arr) => (
|
||||||
|
<div
|
||||||
|
key={row.title}
|
||||||
|
style={{
|
||||||
|
borderBottom: i < arr.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleRow {...row} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useApp } from '../store';
|
||||||
|
import { useClock } from '../hooks/useClock';
|
||||||
|
import { CORE_APPS } from '../data/apps';
|
||||||
|
import type { AppId } from '../types';
|
||||||
|
import AppIcon from '../components/AppIcon';
|
||||||
|
import Dock from '../components/Dock';
|
||||||
|
import BalanceWidget from '../components/widgets/BalanceWidget';
|
||||||
|
import ContractsWidget from '../components/widgets/ContractsWidget';
|
||||||
|
import CertsWidget from '../components/widgets/CertsWidget';
|
||||||
|
import IStore from '../overlays/IStore';
|
||||||
|
import Settings from '../overlays/Settings';
|
||||||
|
import LiveUsage from '../overlays/LiveUsage';
|
||||||
|
import UmmahIDApp from '../apps/UmmahIDApp';
|
||||||
|
import WalletApp from '../apps/WalletApp';
|
||||||
|
import RamzApp from '../apps/RamzApp';
|
||||||
|
import CertsApp from '../apps/CertsApp';
|
||||||
|
import MockNetApp from '../apps/MockNetApp';
|
||||||
|
import CommunityApp from '../apps/CommunityApp';
|
||||||
|
import PrayerApp from '../apps/PrayerApp';
|
||||||
|
import ChatApp from '../apps/ChatApp';
|
||||||
|
import ZakatVaultApp from '../apps/ZakatVaultApp';
|
||||||
|
import WaqfChainApp from '../apps/WaqfChainApp';
|
||||||
|
import QurbanApp from '../apps/QurbanApp';
|
||||||
|
import SadaqahStreamApp from '../apps/SadaqahStreamApp';
|
||||||
|
import InfaqApp from '../apps/InfaqApp';
|
||||||
|
|
||||||
|
interface ContextMenu {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_ID_MAP: Record<string, AppId | null> = {
|
||||||
|
ummahid: 'ummahid',
|
||||||
|
wallet: 'wallet',
|
||||||
|
'zakat-vault': 'zakat-vault',
|
||||||
|
'waqf-chain': 'waqf-chain',
|
||||||
|
qurban: 'qurban',
|
||||||
|
'sadaqah-stream':'sadaqah-stream',
|
||||||
|
infaq: 'infaq',
|
||||||
|
ramz: 'ramz',
|
||||||
|
certs: 'certs',
|
||||||
|
mocknet: 'mocknet',
|
||||||
|
community: 'community',
|
||||||
|
prayer: 'prayer',
|
||||||
|
chat: 'chat',
|
||||||
|
istore: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Desktop() {
|
||||||
|
const { openOverlay, openApp } = useApp();
|
||||||
|
const { time, date } = useClock();
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenu>({ visible: false, x: 0, y: 0 });
|
||||||
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const layoutRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu({ visible: true, x: e.clientX, y: e.clientY });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeContextMenu = useCallback(() => {
|
||||||
|
setContextMenu(prev => ({ ...prev, visible: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (_e: MouseEvent) => {
|
||||||
|
if (contextMenu.visible) closeContextMenu();
|
||||||
|
};
|
||||||
|
window.addEventListener('click', handler);
|
||||||
|
return () => window.removeEventListener('click', handler);
|
||||||
|
}, [contextMenu.visible, closeContextMenu]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === '/' && document.activeElement !== searchRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextMenuItems = [
|
||||||
|
{ label: 'Change Wallpaper', action: () => openOverlay('settings') },
|
||||||
|
{ label: 'Open iStore', action: () => openOverlay('istore') },
|
||||||
|
{ label: 'System Info', action: () => openOverlay('live-usage') },
|
||||||
|
{ label: 'About Falah OS', action: () => {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={layoutRef}
|
||||||
|
className="desktop-layout"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
>
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '28px 48px 120px', minHeight: 0 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: '26px', fontWeight: 700, margin: 0, color: 'var(--brand)', fontFamily: 'Cinzel, Georgia, serif', letterSpacing: '0.5px' }}>
|
||||||
|
☽ Falah OS
|
||||||
|
</h1>
|
||||||
|
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '13px', fontFamily: 'JetBrains Mono, monospace' }}>
|
||||||
|
Community Edition · All systems operational
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '22px', fontWeight: 300, color: 'var(--text)' }}>{time}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>{date}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Widgets Row */}
|
||||||
|
<div style={{ display: 'flex', gap: '14px', marginBottom: '1.75rem', flexShrink: 0 }}>
|
||||||
|
<BalanceWidget />
|
||||||
|
<ContractsWidget />
|
||||||
|
<CertsWidget />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apps Section */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: '12px',
|
||||||
|
maxWidth: '700px',
|
||||||
|
margin: '0 auto',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CORE_APPS.map(app => (
|
||||||
|
<AppIcon
|
||||||
|
key={app.id}
|
||||||
|
app={app}
|
||||||
|
onClick={() => {
|
||||||
|
const appId = APP_ID_MAP[app.id]
|
||||||
|
if (appId) openApp(appId)
|
||||||
|
else if (app.id === 'istore') openOverlay('istore')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '320px',
|
||||||
|
margin: '1.5rem auto 0',
|
||||||
|
borderRadius: '99px',
|
||||||
|
background: searchFocused ? 'rgba(255,255,255,0.10)' : 'rgba(255,255,255,0.07)',
|
||||||
|
border: `1px solid ${searchFocused ? 'var(--brand)' : 'var(--glass-border)'}`,
|
||||||
|
padding: '10px 18px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
transition: 'border-color 0.2s, background 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '14px' }}>🔍</span>
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search apps..."
|
||||||
|
onFocus={() => setSearchFocused(true)}
|
||||||
|
onBlur={() => setSearchFocused(false)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<kbd
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '1px 5px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Blur Overlay */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '120px',
|
||||||
|
background: 'linear-gradient(to top, rgba(7,9,12,0.7), transparent)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dock */}
|
||||||
|
<Dock />
|
||||||
|
|
||||||
|
{/* Overlays */}
|
||||||
|
<IStore />
|
||||||
|
<Settings />
|
||||||
|
<LiveUsage />
|
||||||
|
|
||||||
|
{/* App Windows */}
|
||||||
|
<UmmahIDApp />
|
||||||
|
<WalletApp />
|
||||||
|
<ZakatVaultApp />
|
||||||
|
<WaqfChainApp />
|
||||||
|
<QurbanApp />
|
||||||
|
<SadaqahStreamApp />
|
||||||
|
<InfaqApp />
|
||||||
|
<RamzApp />
|
||||||
|
<CertsApp />
|
||||||
|
<MockNetApp />
|
||||||
|
<CommunityApp />
|
||||||
|
<PrayerApp />
|
||||||
|
<ChatApp />
|
||||||
|
|
||||||
|
{/* Context Menu */}
|
||||||
|
{contextMenu.visible && (
|
||||||
|
<div
|
||||||
|
className="glass"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: contextMenu.y,
|
||||||
|
left: contextMenu.x,
|
||||||
|
zIndex: 300,
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '6px',
|
||||||
|
minWidth: '180px',
|
||||||
|
boxShadow: 'var(--shadow-modal)',
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{contextMenuItems.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
onClick={() => { item.action(); closeContextMenu(); }}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: '7px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--glass-hover)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
function Logo({ size }: { size: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size * 0.225,
|
||||||
|
background: 'linear-gradient(145deg, #10B981, #059669)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: size * 0.45,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: 'white',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: '0 8px 32px rgba(16,185,129,0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
F
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { navigate } = useApp()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [emailFocused, setEmailFocused] = useState(false)
|
||||||
|
const [passwordFocused, setPasswordFocused] = useState(false)
|
||||||
|
|
||||||
|
const inputStyle = (focused: boolean): React.CSSProperties => ({
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: focused ? 'rgba(16,185,129,0.08)' : 'rgba(255,255,255,0.06)',
|
||||||
|
border: `1px solid ${focused ? 'var(--brand)' : 'var(--glass-border)'}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: 14,
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignIn = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
navigate('desktop')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSignIn}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
background: 'rgba(12,26,46,0.92)',
|
||||||
|
backdropFilter: 'blur(24px)',
|
||||||
|
WebkitBackdropFilter: 'blur(24px)',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 40,
|
||||||
|
boxShadow: 'var(--shadow-modal)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Arabic */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'var(--green)',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
opacity: 0.7,
|
||||||
|
marginBottom: 20,
|
||||||
|
fontFamily: 'serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بِسْمِ اللَّهِ
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<Logo size={72} />
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text)',
|
||||||
|
margin: '16px 0 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Falah OS
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
margin: '0 0 32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sovereign Digital Economy
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Email field */}
|
||||||
|
<div style={{ width: '100%', marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>EMAIL</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="operator@falah.os"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onFocus={() => setEmailFocused(true)}
|
||||||
|
onBlur={() => setEmailFocused(false)}
|
||||||
|
style={inputStyle(emailFocused)}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password field */}
|
||||||
|
<div style={{ width: '100%', marginBottom: 28 }}>
|
||||||
|
<label style={labelStyle}>PASSWORD</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onFocus={() => setPasswordFocused(true)}
|
||||||
|
onBlur={() => setPasswordFocused(false)}
|
||||||
|
style={inputStyle(passwordFocused)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign In button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ width: '100%', marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
textAlign: 'center',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Falah OS CE v1.3 · Community Edition
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
|
||||||
|
type Language = 'en' | 'ar' | 'ms'
|
||||||
|
|
||||||
|
const LANGUAGES: { id: Language; label: string }[] = [
|
||||||
|
{ id: 'en', label: 'English' },
|
||||||
|
{ id: 'ar', label: 'العربية' },
|
||||||
|
{ id: 'ms', label: 'Bahasa' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SERVICES = [
|
||||||
|
{ icon: '🪪', name: 'Ummah ID' },
|
||||||
|
{ icon: '💎', name: 'Wallet' },
|
||||||
|
{ icon: '📜', name: 'RAMZ Contracts' },
|
||||||
|
{ icon: '🔬', name: 'Mock-Net' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FAKE_ADDRESS = '0x7f3a9c21b84d56f0e839c74a12d5e3a17c8b2e'
|
||||||
|
|
||||||
|
function Logo({ size }: { size: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size * 0.225,
|
||||||
|
background: 'linear-gradient(145deg, #10B981, #059669)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: size * 0.45,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: 'white',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: '0 8px 32px rgba(16,185,129,0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
F
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step1({ onContinue }: { onContinue: () => void }) {
|
||||||
|
const [lang, setLang] = useState<Language>('en')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--green)',
|
||||||
|
fontSize: 20,
|
||||||
|
letterSpacing: 2,
|
||||||
|
opacity: 0.7,
|
||||||
|
marginBottom: 24,
|
||||||
|
fontFamily: 'serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بِسْمِ اللَّهِ
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 24 }}>
|
||||||
|
<Logo size={80} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--text)',
|
||||||
|
margin: '0 0 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Welcome to Falah OS
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
margin: '0 0 32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
A sovereign, Shariah-compliant digital economy platform
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LANGUAGES.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.id}
|
||||||
|
onClick={() => setLang(l.id)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 18px',
|
||||||
|
borderRadius: 99,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
border: '1px solid',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
background: lang === l.id ? 'var(--brand)' : 'var(--glass)',
|
||||||
|
borderColor: lang === l.id ? 'var(--brand)' : 'var(--glass-border)',
|
||||||
|
color: lang === l.id ? 'white' : 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-primary" style={{ width: '100%' }} onClick={onContinue}>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step2({ onContinue }: { onContinue: () => void }) {
|
||||||
|
const [address, setAddress] = useState<string | null>(null)
|
||||||
|
const [generating, setGenerating] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setAddress(FAKE_ADDRESS)
|
||||||
|
setGenerating(false)
|
||||||
|
}, 1500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text)',
|
||||||
|
margin: '0 0 10px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate Your Wallet
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 28,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
A non-custodial FLH wallet is created locally
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'rgba(16,185,129,0.06)',
|
||||||
|
border: '1px solid rgba(16,185,129,0.2)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '16px 20px',
|
||||||
|
marginBottom: 28,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generating ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--brand)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
animation: 'pulse 1.2s ease-in-out infinite',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generating...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--brand)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{address}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginBottom: 32 }}>
|
||||||
|
{[
|
||||||
|
'12-word seed phrase',
|
||||||
|
'Non-custodial — only you hold the keys',
|
||||||
|
'Shariah-compliant by default',
|
||||||
|
].map((text) => (
|
||||||
|
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 99,
|
||||||
|
background: 'rgba(16,185,129,0.15)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--brand)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>{text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-primary" style={{ width: '100%' }} onClick={onContinue}>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step3({ onFinish }: { onFinish: () => void }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text)',
|
||||||
|
margin: '0 0 10px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Falah OS is Ready
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 28,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
All systems operational
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 32 }}>
|
||||||
|
{SERVICES.map((svc) => (
|
||||||
|
<div
|
||||||
|
key={svc.name}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'var(--glass)',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span style={{ fontSize: 20 }}>{svc.icon}</span>
|
||||||
|
<span style={{ color: 'var(--text)', fontSize: 14, fontWeight: 500 }}>
|
||||||
|
{svc.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--brand)',
|
||||||
|
background: 'rgba(16,185,129,0.12)',
|
||||||
|
border: '1px solid rgba(16,185,129,0.25)',
|
||||||
|
borderRadius: 99,
|
||||||
|
padding: '3px 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-primary" style={{ width: '100%' }} onClick={onFinish}>
|
||||||
|
Launch Falah OS
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Onboarding() {
|
||||||
|
const { navigate } = useApp()
|
||||||
|
const [step, setStep] = useState(0)
|
||||||
|
|
||||||
|
const next = () => setStep((s) => s + 1)
|
||||||
|
const finish = () => navigate('login')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 480,
|
||||||
|
background: 'rgba(12,26,46,0.85)',
|
||||||
|
backdropFilter: 'blur(24px)',
|
||||||
|
WebkitBackdropFilter: 'blur(24px)',
|
||||||
|
border: '1px solid var(--glass-border)',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 48,
|
||||||
|
boxShadow: 'var(--shadow-modal)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step === 0 && <Step1 onContinue={next} />}
|
||||||
|
{step === 1 && <Step2 onContinue={next} />}
|
||||||
|
{step === 2 && <Step3 onFinish={finish} />}
|
||||||
|
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: i === step ? 24 : 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 99,
|
||||||
|
background: i === step ? 'var(--brand)' : 'var(--glass-border)',
|
||||||
|
transition: 'all 0.3s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||||
|
import type { Screen, Overlay, Wallpaper, AppId, OpenWindow, AuthUser } from './types'
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
screen: Screen
|
||||||
|
overlay: Overlay
|
||||||
|
wallpaper: Wallpaper
|
||||||
|
windows: OpenWindow[]
|
||||||
|
authToken: string | null
|
||||||
|
authUser: AuthUser | null
|
||||||
|
dockAutoHide: boolean
|
||||||
|
navigate: (s: Screen) => void
|
||||||
|
openOverlay: (o: Overlay) => void
|
||||||
|
closeOverlay: () => void
|
||||||
|
setWallpaper: (w: Wallpaper) => void
|
||||||
|
openApp: (id: AppId) => void
|
||||||
|
closeApp: (id: AppId) => void
|
||||||
|
focusApp: (id: AppId) => void
|
||||||
|
moveWindow: (id: AppId, pos: { x: number; y: number }) => void
|
||||||
|
setAuth: (token: string, user: AuthUser) => void
|
||||||
|
clearAuth: () => void
|
||||||
|
toggleDockAutoHide: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppCtx = createContext<AppState>(null!)
|
||||||
|
|
||||||
|
export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [screen, setScreen] = useState<Screen>('onboarding')
|
||||||
|
const [overlay, setOverlay] = useState<Overlay>(null)
|
||||||
|
const [wallpaper, setWallpaper] = useState<Wallpaper>('default')
|
||||||
|
const [windows, setWindows] = useState<OpenWindow[]>([])
|
||||||
|
const [authToken, setAuthToken] = useState<string | null>(null)
|
||||||
|
const [authUser, setAuthUser] = useState<AuthUser | null>(null)
|
||||||
|
const [dockAutoHide, setDockAutoHide] = useState(false)
|
||||||
|
|
||||||
|
const openApp = useCallback((id: AppId) => {
|
||||||
|
const now = Date.now()
|
||||||
|
setWindows(prev => {
|
||||||
|
if (prev.find(w => w.id === id)) {
|
||||||
|
return prev.map(w => w.id === id ? { ...w, zIndex: now } : w)
|
||||||
|
}
|
||||||
|
const offset = prev.length * 28
|
||||||
|
return [...prev, { id, zIndex: now, position: { x: 80 + offset, y: 55 + offset } }]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeApp = useCallback((id: AppId) => {
|
||||||
|
setWindows(prev => prev.filter(w => w.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const focusApp = useCallback((id: AppId) => {
|
||||||
|
setWindows(prev => prev.map(w => w.id === id ? { ...w, zIndex: Date.now() } : w))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const moveWindow = useCallback((id: AppId, pos: { x: number; y: number }) => {
|
||||||
|
setWindows(prev => prev.map(w => w.id === id ? { ...w, position: pos } : w))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setAuth = useCallback((token: string, user: AuthUser) => {
|
||||||
|
setAuthToken(token)
|
||||||
|
setAuthUser(user)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearAuth = useCallback(() => {
|
||||||
|
setAuthToken(null)
|
||||||
|
setAuthUser(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleDockAutoHide = useCallback(() => setDockAutoHide(prev => !prev), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppCtx.Provider value={{
|
||||||
|
screen, overlay, wallpaper, windows, authToken, authUser, dockAutoHide,
|
||||||
|
navigate: setScreen,
|
||||||
|
openOverlay: setOverlay,
|
||||||
|
closeOverlay: () => setOverlay(null),
|
||||||
|
setWallpaper,
|
||||||
|
openApp, closeApp, focusApp, moveWindow,
|
||||||
|
setAuth, clearAuth,
|
||||||
|
toggleDockAutoHide,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</AppCtx.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useApp = () => useContext(AppCtx)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type Screen = 'onboarding' | 'login' | 'desktop'
|
||||||
|
export type Overlay = 'istore' | 'settings' | 'live-usage' | null
|
||||||
|
export type Wallpaper = 'default' | 'teal' | 'gold' | 'sapphire'
|
||||||
|
export type AppId = 'ummahid' | 'wallet' | 'ramz' | 'certs' | 'mocknet' | 'community' | 'prayer' | 'chat' | 'zakat-vault' | 'waqf-chain' | 'qurban' | 'sadaqah-stream' | 'infaq'
|
||||||
|
|
||||||
|
export interface OpenWindow {
|
||||||
|
id: AppId
|
||||||
|
zIndex: number
|
||||||
|
position: { x: number; y: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
fullName: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FalahApp {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
iconClass: string
|
||||||
|
tagline: string
|
||||||
|
category: string
|
||||||
|
ramzVerified: boolean
|
||||||
|
installed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WallpaperOption {
|
||||||
|
id: Wallpaper
|
||||||
|
label: string
|
||||||
|
swatchClass: string
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { useApp } from '../store'
|
||||||
|
import type { AppId } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: AppId
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppWindow({ id, title, icon, width = 440, height = 500, children }: Props) {
|
||||||
|
const { windows, closeApp, focusApp, moveWindow } = useApp()
|
||||||
|
const win = windows.find(w => w.id === id)
|
||||||
|
const [pos, setPos] = useState(win?.position ?? { x: 80, y: 55 })
|
||||||
|
const dragOffset = useRef({ x: 0, y: 0 })
|
||||||
|
const dragging = useRef(false)
|
||||||
|
|
||||||
|
if (!win) return null
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
dragging.current = true
|
||||||
|
dragOffset.current = { x: e.clientX - pos.x, y: e.clientY - pos.y }
|
||||||
|
focusApp(id)
|
||||||
|
}
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragging.current) return
|
||||||
|
const newPos = { x: e.clientX - dragOffset.current.x, y: e.clientY - dragOffset.current.y }
|
||||||
|
setPos(newPos)
|
||||||
|
moveWindow(id, newPos)
|
||||||
|
}
|
||||||
|
const handlePointerUp = () => { dragging.current = false }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onPointerDown={() => focusApp(id)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: pos.y,
|
||||||
|
left: pos.x,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
zIndex: win.zIndex,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRadius: '14px',
|
||||||
|
background: 'rgba(12,26,46,0.92)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.10)',
|
||||||
|
boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
|
||||||
|
backdropFilter: 'blur(24px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title bar */}
|
||||||
|
<div
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
cursor: 'grab',
|
||||||
|
userSelect: 'none',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: '18px' }}>{icon}</span>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text)' }}>{title}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onPointerDown={e => e.stopPropagation()}
|
||||||
|
onClick={() => closeApp(id)}
|
||||||
|
style={{
|
||||||
|
width: '20px', height: '20px', borderRadius: '50%',
|
||||||
|
background: 'rgba(255,80,80,0.7)', border: 'none',
|
||||||
|
cursor: 'pointer', fontSize: '11px', color: 'rgba(0,0,0,0.6)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '16px' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: { DEFAULT: '#C9A84C', dark: '#B8963A', light: '#E8C97A' },
|
||||||
|
gold: { DEFAULT: '#C9A84C', light: '#E8C97A' },
|
||||||
|
green: { DEFAULT: '#00C48C', light: '#00E09E' },
|
||||||
|
bg: { deep: '#07090C', surface: '#0C1A2E', elevated: '#112236' },
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
|
heading: ['Cinzel', 'Georgia', 'serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Courier New', 'monospace'],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
'app': '18px',
|
||||||
|
'xl2': '28px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user