feat: integrate 5 charity modules + scrollable desktop + dock autohide

This commit is contained in:
Antigravity AI
2026-06-03 14:00:29 +08:00
commit f1f717cbcb
50 changed files with 10166 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.vite/
dist/
.DS_Store
graphify-out/
*.log
.env
*.bak
+23
View File
@@ -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
View File
@@ -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>
+3559
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"name": "falah-app-runtime",
"version": "1.3.0",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5"
}
}
+29
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+22
View File
@@ -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}` }));
});
+99
View File
@@ -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
View File
@@ -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>
)
}
+171
View File
@@ -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>
)
}
+248
View File
@@ -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>
)
}
+307
View File
@@ -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>
)
}
+374
View File
@@ -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>
)
}
+156
View File
@@ -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>
)
}
+170
View File
@@ -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>
)
}
+285
View File
@@ -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>
)
}
+191
View File
@@ -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>
)
}
+317
View File
@@ -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>
)
}
+118
View File
@@ -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>
)
}
+177
View File
@@ -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>
)
}
+334
View File
@@ -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>
)
}
+329
View File
@@ -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>
)
}
+61
View File
@@ -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>
);
}
+257
View File
@@ -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>
);
}
+30
View File
@@ -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 };
}
+103
View File
@@ -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;
+79
View File
@@ -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;
+39
View File
@@ -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']
+8
View File
@@ -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' },
]
+28
View File
@@ -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>
}
+15
View File
@@ -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
View File
@@ -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; }
+13
View File
@@ -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>,
)
+478
View File
@@ -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>
)
}
+266
View File
@@ -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>
)
}
+401
View File
@@ -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>
)
}
+284
View File
@@ -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>
);
}
+182
View File
@@ -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>
)
}
+373
View File
@@ -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>
)
}
+87
View File
@@ -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)
+34
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+95
View File
@@ -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>
)
}
+24
View File
@@ -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: [],
}
+22
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+20
View File
@@ -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"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})