@bhooai/nexus-examples 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/examples/ai-chat/README.md +37 -0
- package/examples/ai-chat/apps/backend/src/routes/chat.ts +40 -0
- package/examples/ai-chat/apps/frontend/src/main.tsx +105 -0
- package/examples/blog-crud/README.md +45 -0
- package/examples/blog-crud/apps/backend/src/graphql/post.graph.ts +99 -0
- package/examples/blog-crud/apps/backend/src/models/Author.ts +10 -0
- package/examples/blog-crud/apps/backend/src/models/Comment.ts +10 -0
- package/examples/blog-crud/apps/backend/src/models/Post.ts +13 -0
- package/examples/blog-crud/apps/backend/src/routes/posts.ts +119 -0
- package/examples/blog-crud/apps/frontend/src/main.tsx +102 -0
- package/examples/chat/README.md +20 -0
- package/examples/chat/apps/backend/src/models/Message.ts +11 -0
- package/examples/chat/apps/backend/src/routes/chat.ts +17 -0
- package/examples/chat/apps/backend/src/ws/chat.room.ts +56 -0
- package/examples/chat/apps/frontend/src/main.tsx +97 -0
- package/examples/checkout/README.md +40 -0
- package/examples/checkout/apps/backend/src/mail/mailables/OrderConfirmationMail.ts +27 -0
- package/examples/checkout/apps/backend/src/mail/templates/order-confirmation.ejs +28 -0
- package/examples/checkout/apps/backend/src/models/Order.ts +14 -0
- package/examples/checkout/apps/backend/src/routes/checkout.ts +74 -0
- package/examples/checkout/apps/frontend/src/main.tsx +85 -0
- package/examples/dashboard/README.md +26 -0
- package/examples/dashboard/apps/backend/src/routes/metrics.ts +76 -0
- package/examples/dashboard/apps/frontend/src/main.tsx +100 -0
- package/examples/file-storage/README.md +48 -0
- package/examples/file-storage/apps/backend/src/routes/files.ts +86 -0
- package/examples/file-storage/apps/frontend/src/main.tsx +111 -0
- package/examples/livestream/README.md +34 -0
- package/examples/livestream/apps/backend/src/routes/list.ts +21 -0
- package/examples/livestream/apps/backend/src/ws/stream.room.ts +53 -0
- package/examples/livestream/apps/frontend/src/main.tsx +148 -0
- package/examples/multi-app/README.md +51 -0
- package/examples/multi-app/apps/backend-api/src/routes/users.ts +26 -0
- package/examples/multi-app/apps/backend-shop/src/routes/products.ts +27 -0
- package/examples/saas-starter/README.md +37 -0
- package/examples/saas-starter/apps/backend/src/events/TeamCreated.ts +11 -0
- package/examples/saas-starter/apps/backend/src/events/UserRegistered.ts +10 -0
- package/examples/saas-starter/apps/backend/src/listeners/OnTeamCreated.ts +11 -0
- package/examples/saas-starter/apps/backend/src/listeners/OnUserRegistered.ts +10 -0
- package/examples/saas-starter/apps/backend/src/middleware/auth.ts +14 -0
- package/examples/saas-starter/apps/backend/src/models/Membership.ts +20 -0
- package/examples/saas-starter/apps/backend/src/models/Team.ts +10 -0
- package/examples/saas-starter/apps/backend/src/models/User.ts +17 -0
- package/examples/saas-starter/apps/backend/src/policies/TeamPolicy.ts +35 -0
- package/examples/saas-starter/apps/backend/src/routes/projects.ts +52 -0
- package/examples/saas-starter/apps/backend/src/routes/teams.ts +112 -0
- package/examples/saas-starter/apps/frontend/src/main.tsx +95 -0
- package/examples/video-call/README.md +30 -0
- package/examples/video-call/apps/backend/src/ws/signaling.room.ts +47 -0
- package/examples/video-call/apps/frontend/src/main.tsx +116 -0
- package/package.json +11 -0
- package/src/index.ts +99 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat WebSocket room. Auto-discovered by createNexusApp() from ws/*.room.ts.
|
|
3
|
+
*
|
|
4
|
+
* Client protocol:
|
|
5
|
+
* → join { roomId, username }
|
|
6
|
+
* ← presence { users: [...] }
|
|
7
|
+
* → msg { roomId, text }
|
|
8
|
+
* ← msg { from, text, at }
|
|
9
|
+
* → typing { roomId }
|
|
10
|
+
* ← typing { username }
|
|
11
|
+
*/
|
|
12
|
+
export default {
|
|
13
|
+
name: 'chat',
|
|
14
|
+
|
|
15
|
+
async onJoin(socket: any, payload: { roomId: string; username: string }) {
|
|
16
|
+
socket.data.username = payload.username;
|
|
17
|
+
socket.join(`chat:${payload.roomId}`);
|
|
18
|
+
socket.to(`chat:${payload.roomId}`).emit('presence', {
|
|
19
|
+
type: 'join',
|
|
20
|
+
username: payload.username,
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async onLeave(socket: any, payload: { roomId: string }) {
|
|
25
|
+
socket.to(`chat:${payload.roomId}`).emit('presence', {
|
|
26
|
+
type: 'leave',
|
|
27
|
+
username: socket.data?.username,
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
onMessage: {
|
|
32
|
+
'msg': async (socket: any, payload: { roomId: string; text: string }, ctx: any) => {
|
|
33
|
+
const msg = {
|
|
34
|
+
from: socket.data?.username ?? 'anon',
|
|
35
|
+
text: payload.text,
|
|
36
|
+
at: new Date().toISOString(),
|
|
37
|
+
};
|
|
38
|
+
// Persist (optional; async so we don't block broadcast)
|
|
39
|
+
try {
|
|
40
|
+
const { Message } = await import('../models/Message.js');
|
|
41
|
+
await Message.create({
|
|
42
|
+
roomId: payload.roomId,
|
|
43
|
+
userId: socket.data?.userId ?? 'anon',
|
|
44
|
+
username: msg.from,
|
|
45
|
+
text: msg.text,
|
|
46
|
+
});
|
|
47
|
+
} catch { /* swallow */ }
|
|
48
|
+
ctx.server.to(`chat:${payload.roomId}`).emit('msg', msg);
|
|
49
|
+
},
|
|
50
|
+
'typing': (socket: any, payload: { roomId: string }, ctx: any) => {
|
|
51
|
+
socket.to(`chat:${payload.roomId}`).emit('typing', {
|
|
52
|
+
username: socket.data?.username,
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
|
|
4
|
+
interface Msg { from: string; text: string; at: string }
|
|
5
|
+
|
|
6
|
+
function App() {
|
|
7
|
+
const [roomId, setRoomId] = useState('lobby');
|
|
8
|
+
const [username, setUsername] = useState(`guest-${Math.floor(Math.random() * 1000)}`);
|
|
9
|
+
const [messages, setMessages] = useState<Msg[]>([]);
|
|
10
|
+
const [draft, setDraft] = useState('');
|
|
11
|
+
const [presence, setPresence] = useState<string[]>([]);
|
|
12
|
+
const [typingUsers, setTypingUsers] = useState<string[]>([]);
|
|
13
|
+
const [connected, setConnected] = useState(false);
|
|
14
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const host = (import.meta as any).env?.VITE_BACKEND_HOST ?? 'localhost';
|
|
18
|
+
const port = (import.meta as any).env?.VITE_BACKEND_PORT ?? '4000';
|
|
19
|
+
const ws = new WebSocket(`ws://${host}:${port}/ws`);
|
|
20
|
+
wsRef.current = ws;
|
|
21
|
+
|
|
22
|
+
ws.onopen = () => {
|
|
23
|
+
setConnected(true);
|
|
24
|
+
ws.send(JSON.stringify({ type: 'join', roomId, username }));
|
|
25
|
+
};
|
|
26
|
+
ws.onclose = () => setConnected(false);
|
|
27
|
+
ws.onmessage = (ev) => {
|
|
28
|
+
try {
|
|
29
|
+
const data = JSON.parse(ev.data);
|
|
30
|
+
if (data.type === 'msg') setMessages((m) => [...m, data.payload]);
|
|
31
|
+
else if (data.type === 'presence') {
|
|
32
|
+
setPresence((p) => {
|
|
33
|
+
if (data.payload.type === 'join') return [...new Set([...p, data.payload.username])];
|
|
34
|
+
return p.filter((u) => u !== data.payload.username);
|
|
35
|
+
});
|
|
36
|
+
} else if (data.type === 'typing') {
|
|
37
|
+
setTypingUsers((t) => [...new Set([...t, data.payload.username])]);
|
|
38
|
+
setTimeout(() => setTypingUsers((t) => t.filter((u) => u !== data.payload.username)), 2000);
|
|
39
|
+
}
|
|
40
|
+
} catch { /* ignore */ }
|
|
41
|
+
};
|
|
42
|
+
return () => ws.close();
|
|
43
|
+
}, [roomId, username]);
|
|
44
|
+
|
|
45
|
+
// Load history on room change
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
fetch(`/api/chat/rooms/${roomId}/messages`)
|
|
48
|
+
.then((r) => r.json())
|
|
49
|
+
.then((data) => setMessages(data.messages ?? []))
|
|
50
|
+
.catch(() => {});
|
|
51
|
+
}, [roomId]);
|
|
52
|
+
|
|
53
|
+
const send = () => {
|
|
54
|
+
if (!draft.trim() || !wsRef.current) return;
|
|
55
|
+
wsRef.current.send(JSON.stringify({ type: 'msg', roomId, text: draft }));
|
|
56
|
+
setDraft('');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const onType = (v: string) => {
|
|
60
|
+
setDraft(v);
|
|
61
|
+
if (wsRef.current && v.length % 3 === 0) {
|
|
62
|
+
wsRef.current.send(JSON.stringify({ type: 'typing', roomId }));
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<main style={{ fontFamily: 'system-ui', maxWidth: 720, margin: '2rem auto', padding: '0 1rem' }}>
|
|
68
|
+
<h1>Chat — room: {roomId}</h1>
|
|
69
|
+
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
|
|
70
|
+
<input value={roomId} onChange={(e) => setRoomId(e.target.value)} placeholder="room" />
|
|
71
|
+
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
|
|
72
|
+
<span>{connected ? '●' : '○'}</span>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div style={{ fontSize: '0.85rem', color: '#666', marginBottom: '0.5rem' }}>
|
|
76
|
+
Online: {presence.join(', ') || '—'}
|
|
77
|
+
{typingUsers.length > 0 && <em> · typing: {typingUsers.join(', ')}</em>}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div style={{ border: '1px solid #ccc', borderRadius: 8, padding: '1rem', minHeight: 300, maxHeight: 400, overflow: 'auto', marginBottom: '1rem', background: '#fafafa' }}>
|
|
81
|
+
{messages.map((m, i) => (
|
|
82
|
+
<div key={i} style={{ marginBottom: '0.5rem' }}>
|
|
83
|
+
<strong>{m.from}</strong>: {m.text} <span style={{ color: '#999', fontSize: '0.75rem' }}>{new Date(m.at).toLocaleTimeString()}</span>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
89
|
+
<input value={draft} onChange={(e) => onType(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} placeholder="Type a message…" style={{ flex: 1, padding: '0.5rem' }} />
|
|
90
|
+
<button onClick={send}>Send</button>
|
|
91
|
+
</div>
|
|
92
|
+
</main>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const el = document.getElementById('root');
|
|
97
|
+
if (el) createRoot(el).render(<App />);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Checkout example
|
|
2
|
+
|
|
3
|
+
Razorpay-style payment flow: order creation on the backend, a signed webhook
|
|
4
|
+
for payment confirmation, and a confirmation mailable queued on success.
|
|
5
|
+
|
|
6
|
+
## What's here
|
|
7
|
+
|
|
8
|
+
- `apps/backend/src/routes/checkout.ts` — `POST /api/checkout/order` + `POST /api/checkout/webhook`
|
|
9
|
+
- `apps/backend/src/models/Order.ts` — order document (`created` -> `paid` / `failed`)
|
|
10
|
+
- `apps/backend/src/mail/mailables/OrderConfirmationMail.ts` — Mailable subclass
|
|
11
|
+
- `apps/backend/src/mail/templates/order-confirmation.ejs` — receipt template
|
|
12
|
+
- `apps/frontend/src/main.tsx` — cart + checkout button (mock; no real provider call)
|
|
13
|
+
|
|
14
|
+
## Flow
|
|
15
|
+
|
|
16
|
+
1. Frontend posts `{ email, items }` to `/api/checkout/order`.
|
|
17
|
+
2. Backend totals the cart, persists an `Order`, and returns `{ orderId, amount }`.
|
|
18
|
+
(With a real provider this is where `razorpay.orders.create(...)` goes.)
|
|
19
|
+
3. The provider later calls `/api/checkout/webhook`. The signature in
|
|
20
|
+
`x-webhook-signature` is verified with `NEXUS_PAYMENTS_WEBHOOK_SECRET`
|
|
21
|
+
(demo default: `demo-webhook-secret`), the order flips to `paid`, and an
|
|
22
|
+
`OrderConfirmationMail` is queued.
|
|
23
|
+
|
|
24
|
+
### Simulate the webhook
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
$body = '{"orderId":"<id>","status":"captured"}'
|
|
28
|
+
$sig = # HMAC-SHA256 hex of $body with the webhook secret
|
|
29
|
+
curl -X POST http://localhost:4000/api/checkout/webhook \
|
|
30
|
+
-H "content-type: application/json" -H "x-webhook-signature: $sig" -d $body
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Run
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx nexus dev
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Add items to the cart and press *Pay* — the demo stops right before opening a
|
|
40
|
+
real payment modal.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Mailable } from '@bhooai/nexus-core';
|
|
2
|
+
|
|
3
|
+
export interface OrderLike {
|
|
4
|
+
orderId: string;
|
|
5
|
+
email: string;
|
|
6
|
+
amount: number;
|
|
7
|
+
currency: string;
|
|
8
|
+
items: Array<{ name: string; qty: number; price: number }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class OrderConfirmationMail extends Mailable {
|
|
12
|
+
subject = 'Your order is confirmed';
|
|
13
|
+
template = 'order-confirmation';
|
|
14
|
+
|
|
15
|
+
constructor(public order: OrderLike) {
|
|
16
|
+
super();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
data(): Record<string, unknown> {
|
|
20
|
+
return {
|
|
21
|
+
orderId: this.order.orderId,
|
|
22
|
+
total: (this.order.amount / 100).toFixed(2),
|
|
23
|
+
currency: this.order.currency,
|
|
24
|
+
items: this.order.items,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<body style="font-family: system-ui, sans-serif; color: #222;">
|
|
4
|
+
<h1>Thanks for your order!</h1>
|
|
5
|
+
<p>Order <strong><%= orderId %></strong> has been confirmed.</p>
|
|
6
|
+
<table cellpadding="6" style="border-collapse: collapse;">
|
|
7
|
+
<thead>
|
|
8
|
+
<tr><th align="left">Item</th><th align="right">Qty</th><th align="right">Price</th></tr>
|
|
9
|
+
</thead>
|
|
10
|
+
<tbody>
|
|
11
|
+
<% items.forEach(function (item) { %>
|
|
12
|
+
<tr>
|
|
13
|
+
<td><%= item.name %></td>
|
|
14
|
+
<td align="right"><%= item.qty %></td>
|
|
15
|
+
<td align="right"><%= (item.price / 100).toFixed(2) %></td>
|
|
16
|
+
</tr>
|
|
17
|
+
<% }); %>
|
|
18
|
+
</tbody>
|
|
19
|
+
<tfoot>
|
|
20
|
+
<tr>
|
|
21
|
+
<td colspan="2" align="right"><strong>Total</strong></td>
|
|
22
|
+
<td align="right"><strong><%= currency %> <%= total %></strong></td>
|
|
23
|
+
</tr>
|
|
24
|
+
</tfoot>
|
|
25
|
+
</table>
|
|
26
|
+
<p style="color: #666; font-size: 0.85rem;">This is a demo receipt — no real charge was made.</p>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { model, Schema } from '@bhooai/nexus-data';
|
|
2
|
+
|
|
3
|
+
const orderSchema = new Schema({
|
|
4
|
+
orderId: { type: String, required: true, index: true },
|
|
5
|
+
email: { type: String, required: true },
|
|
6
|
+
items: { type: Array, default: () => [] },
|
|
7
|
+
amount: { type: Number, required: true }, // smallest currency unit (paise)
|
|
8
|
+
currency: { type: String, default: 'INR' },
|
|
9
|
+
status: { type: String, default: 'created', enum: ['created', 'paid', 'failed'] },
|
|
10
|
+
receipt: { type: String },
|
|
11
|
+
createdAt: { type: Date, default: () => new Date(), index: true },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const Order = model('orders', orderSchema);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
2
|
+
import { defineRoutes } from '@bhooai/nexus-core';
|
|
3
|
+
import { Order } from '../models/Order.js';
|
|
4
|
+
import { OrderConfirmationMail } from '../mail/mailables/OrderConfirmationMail.js';
|
|
5
|
+
|
|
6
|
+
interface CartItem { name: string; qty: number; price: number }
|
|
7
|
+
interface OrderDoc {
|
|
8
|
+
orderId: string;
|
|
9
|
+
email: string;
|
|
10
|
+
amount: number;
|
|
11
|
+
currency: string;
|
|
12
|
+
items: CartItem[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const WEBHOOK_SECRET = process.env.NEXUS_PAYMENTS_WEBHOOK_SECRET ?? 'demo-webhook-secret';
|
|
16
|
+
|
|
17
|
+
export default defineRoutes([
|
|
18
|
+
{
|
|
19
|
+
// Create a fake provider order. With a real provider you'd call
|
|
20
|
+
// razorpay.orders.create({ amount, currency, receipt }) here instead.
|
|
21
|
+
method: 'POST',
|
|
22
|
+
path: '/api/checkout/order',
|
|
23
|
+
handler: async (ctx) => {
|
|
24
|
+
const body = (ctx.body ?? {}) as { email?: string; items?: CartItem[] };
|
|
25
|
+
if (!body.email || !Array.isArray(body.items) || body.items.length === 0) {
|
|
26
|
+
return ctx.json({ error: 'email and items are required' }, 422);
|
|
27
|
+
}
|
|
28
|
+
const amount = body.items.reduce((sum, i) => sum + i.price * i.qty, 0);
|
|
29
|
+
const orderId = `order_${randomUUID().slice(0, 12)}`;
|
|
30
|
+
const [order] = await Order.create({
|
|
31
|
+
orderId,
|
|
32
|
+
email: body.email,
|
|
33
|
+
items: body.items,
|
|
34
|
+
amount,
|
|
35
|
+
receipt: `rcpt_${Date.now()}`,
|
|
36
|
+
});
|
|
37
|
+
const doc = order as unknown as OrderDoc;
|
|
38
|
+
ctx.json({ orderId: doc.orderId, amount, currency: 'INR' }, 201);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
// Provider webhook: verify the signature, mark paid, send the receipt.
|
|
43
|
+
method: 'POST',
|
|
44
|
+
path: '/api/checkout/webhook',
|
|
45
|
+
handler: async (ctx) => {
|
|
46
|
+
const body = (ctx.body ?? {}) as { orderId?: string; event?: string; status?: string };
|
|
47
|
+
const signature = String(ctx.headers['x-webhook-signature'] ?? '');
|
|
48
|
+
const expected = createHmac('sha256', WEBHOOK_SECRET)
|
|
49
|
+
.update(JSON.stringify(ctx.body ?? {}))
|
|
50
|
+
.digest('hex');
|
|
51
|
+
if (signature !== expected) {
|
|
52
|
+
return ctx.json({ error: 'invalid signature' }, 401);
|
|
53
|
+
}
|
|
54
|
+
if (!body.orderId) return ctx.json({ error: 'orderId required' }, 422);
|
|
55
|
+
|
|
56
|
+
const status = body.status === 'captured' ? 'paid' : 'failed';
|
|
57
|
+
const order = await Order.findOneAndUpdate(
|
|
58
|
+
{ orderId: body.orderId },
|
|
59
|
+
{ $set: { status } },
|
|
60
|
+
);
|
|
61
|
+
if (order && status === 'paid') {
|
|
62
|
+
const doc = order as unknown as OrderDoc;
|
|
63
|
+
await OrderConfirmationMail.to(doc.email, {
|
|
64
|
+
orderId: doc.orderId,
|
|
65
|
+
email: doc.email,
|
|
66
|
+
amount: doc.amount,
|
|
67
|
+
currency: doc.currency,
|
|
68
|
+
items: doc.items,
|
|
69
|
+
}).queue().catch((err: unknown) => console.error('[checkout] mail failed:', err));
|
|
70
|
+
}
|
|
71
|
+
ctx.json({ received: true });
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
|
|
4
|
+
interface Item { name: string; qty: number; price: number }
|
|
5
|
+
|
|
6
|
+
const CATALOG: Item[] = [
|
|
7
|
+
{ name: 'Nexus mug', qty: 1, price: 49900 },
|
|
8
|
+
{ name: 'Nexus tee', qty: 1, price: 89900 },
|
|
9
|
+
{ name: 'Sticker pack', qty: 1, price: 14900 },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function App() {
|
|
13
|
+
const [email, setEmail] = useState('you@example.com');
|
|
14
|
+
const [cart, setCart] = useState<Item[]>([]);
|
|
15
|
+
const [status, setStatus] = useState('');
|
|
16
|
+
const [orderId, setOrderId] = useState('');
|
|
17
|
+
|
|
18
|
+
const total = useMemo(() => cart.reduce((s, i) => s + i.price * i.qty, 0), [cart]);
|
|
19
|
+
|
|
20
|
+
const add = (item: Item) => {
|
|
21
|
+
setCart((c) => {
|
|
22
|
+
const found = c.find((i) => i.name === item.name);
|
|
23
|
+
if (found) return c.map((i) => (i.name === item.name ? { ...i, qty: i.qty + 1 } : i));
|
|
24
|
+
return [...c, { ...item }];
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const checkout = async () => {
|
|
29
|
+
setStatus('creating order...');
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch('/api/checkout/order', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'content-type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({ email, items: cart }),
|
|
35
|
+
});
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
38
|
+
setOrderId(data.orderId);
|
|
39
|
+
// Real integration would open the Razorpay modal here with the orderId.
|
|
40
|
+
// Demo just pretends the payment succeeded.
|
|
41
|
+
setStatus(`order ${data.orderId} created — payment modal would open here (mocked as paid)`);
|
|
42
|
+
setCart([]);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
setStatus(`error: ${(err as Error).message}`);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<main style={{ fontFamily: 'system-ui', maxWidth: 720, margin: '2rem auto', padding: '0 1rem' }}>
|
|
50
|
+
<h1>Checkout demo</h1>
|
|
51
|
+
|
|
52
|
+
<h2 style={{ fontSize: '1rem' }}>Catalog</h2>
|
|
53
|
+
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
|
|
54
|
+
{CATALOG.map((item) => (
|
|
55
|
+
<div key={item.name} style={{ border: '1px solid #ccc', borderRadius: 8, padding: '0.75rem 1rem' }}>
|
|
56
|
+
<strong>{item.name}</strong>
|
|
57
|
+
<div style={{ color: '#666', fontSize: '0.85rem' }}>INR {(item.price / 100).toFixed(2)}</div>
|
|
58
|
+
<button onClick={() => add(item)} style={{ marginTop: '0.5rem' }}>Add to cart</button>
|
|
59
|
+
</div>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<h2 style={{ fontSize: '1rem' }}>Cart ({cart.reduce((s, i) => s + i.qty, 0)})</h2>
|
|
64
|
+
{cart.length === 0 && <p style={{ color: '#666' }}>Cart is empty.</p>}
|
|
65
|
+
<ul>
|
|
66
|
+
{cart.map((i) => (
|
|
67
|
+
<li key={i.name}>{i.qty} x {i.name} — INR {((i.price * i.qty) / 100).toFixed(2)}</li>
|
|
68
|
+
))}
|
|
69
|
+
</ul>
|
|
70
|
+
|
|
71
|
+
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '1rem' }}>
|
|
72
|
+
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email" style={{ flex: 1, padding: '0.5rem' }} />
|
|
73
|
+
<button onClick={checkout} disabled={cart.length === 0}>
|
|
74
|
+
Pay INR {(total / 100).toFixed(2)}
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{status && <p style={{ color: '#444' }}>{status}</p>}
|
|
79
|
+
{orderId && <p style={{ color: '#666', fontSize: '0.85rem' }}>Last order: {orderId} — webhook flips it to paid and queues a confirmation email.</p>}
|
|
80
|
+
</main>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const el = document.getElementById('root');
|
|
85
|
+
if (el) createRoot(el).render(<App />);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Dashboard example
|
|
2
|
+
|
|
3
|
+
Custom metrics page with zero chart dependencies: ASCII bars, monospace tables,
|
|
4
|
+
and CSS divs. The backend records requests to its own demo endpoints in memory
|
|
5
|
+
and serves aggregates as JSON.
|
|
6
|
+
|
|
7
|
+
## What's here
|
|
8
|
+
|
|
9
|
+
- `apps/backend/src/routes/metrics.ts` — `GET /api/metrics` (requests today, avg latency, top paths, hourly buckets) plus `/api/ping` and `/api/slow` to generate traffic
|
|
10
|
+
- `apps/frontend/src/main.tsx` — KPI cards, monospace bar chart for top paths, CSS-div hourly histogram
|
|
11
|
+
|
|
12
|
+
## Notable
|
|
13
|
+
|
|
14
|
+
- Daily counters roll over at midnight (`rollDay`).
|
|
15
|
+
- `/api/slow` sleeps 120-320ms so you can watch the avg-latency number move.
|
|
16
|
+
- The page polls every 5 seconds — no WS needed for a dashboard.
|
|
17
|
+
|
|
18
|
+
## Run
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx nexus dev
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Open http://localhost:3000 and press the *Hit ...* buttons a few times to see
|
|
25
|
+
the charts fill in. In a real app you'd source these numbers from
|
|
26
|
+
`nexus-telemetry` instead of in-process counters.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { defineRoutes } from '@bhooai/nexus-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory request metrics demo. A real deployment would aggregate from
|
|
5
|
+
* nexus-telemetry / cluster logs; here we track requests to this server's
|
|
6
|
+
* own API to show the shape of the data.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface PathStats { count: number; totalMs: number }
|
|
10
|
+
const stats = new Map<string, PathStats>();
|
|
11
|
+
let todayRequests = 0;
|
|
12
|
+
let dayStamp = new Date().toDateString();
|
|
13
|
+
|
|
14
|
+
function rollDay() {
|
|
15
|
+
const now = new Date().toDateString();
|
|
16
|
+
if (now !== dayStamp) {
|
|
17
|
+
dayStamp = now;
|
|
18
|
+
todayRequests = 0;
|
|
19
|
+
stats.clear();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Cheap middleware-style recorder — call from route handlers you want to count. */
|
|
24
|
+
export function recordRequest(path: string, ms: number) {
|
|
25
|
+
rollDay();
|
|
26
|
+
todayRequests += 1;
|
|
27
|
+
const s = stats.get(path) ?? { count: 0, totalMs: 0 };
|
|
28
|
+
s.count += 1;
|
|
29
|
+
s.totalMs += ms;
|
|
30
|
+
stats.set(path, s);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default defineRoutes([
|
|
34
|
+
{
|
|
35
|
+
method: 'GET',
|
|
36
|
+
path: '/api/metrics',
|
|
37
|
+
handler: async (ctx) => {
|
|
38
|
+
rollDay();
|
|
39
|
+
const topPaths = [...stats.entries()]
|
|
40
|
+
.map(([path, s]) => ({ path, count: s.count, avgMs: Math.round(s.totalMs / s.count) }))
|
|
41
|
+
.sort((a, b) => b.count - a.count)
|
|
42
|
+
.slice(0, 8);
|
|
43
|
+
const totalMs = [...stats.values()].reduce((n, s) => n + s.totalMs, 0);
|
|
44
|
+
ctx.json({
|
|
45
|
+
date: dayStamp,
|
|
46
|
+
requestsToday: todayRequests,
|
|
47
|
+
avgLatencyMs: todayRequests > 0 ? Math.round(totalMs / todayRequests) : 0,
|
|
48
|
+
topPaths,
|
|
49
|
+
// Sparkline-ish fake hourly buckets so the frontend has a series to draw.
|
|
50
|
+
hourly: Array.from({ length: 24 }, (_, h) =>
|
|
51
|
+
h <= new Date().getHours() ? Math.max(0, Math.round(todayRequests / (new Date().getHours() + 1) + (h % 3) * 2)) : 0),
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
// A couple of endpoints to generate traffic worth charting.
|
|
57
|
+
method: 'GET',
|
|
58
|
+
path: '/api/ping',
|
|
59
|
+
handler: async (ctx) => {
|
|
60
|
+
const start = performance.now();
|
|
61
|
+
await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 40)));
|
|
62
|
+
recordRequest('/api/ping', performance.now() - start);
|
|
63
|
+
ctx.json({ pong: true });
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
method: 'GET',
|
|
68
|
+
path: '/api/slow',
|
|
69
|
+
handler: async (ctx) => {
|
|
70
|
+
const start = performance.now();
|
|
71
|
+
await new Promise((r) => setTimeout(r, 120 + Math.floor(Math.random() * 200)));
|
|
72
|
+
recordRequest('/api/slow', performance.now() - start);
|
|
73
|
+
ctx.json({ ok: true });
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
]);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
|
|
4
|
+
interface TopPath { path: string; count: number; avgMs: number }
|
|
5
|
+
interface Metrics {
|
|
6
|
+
date: string;
|
|
7
|
+
requestsToday: number;
|
|
8
|
+
avgLatencyMs: number;
|
|
9
|
+
topPaths: TopPath[];
|
|
10
|
+
hourly: number[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function Bar({ value, max, width = 24 }: { value: number; max: number; width?: number }) {
|
|
14
|
+
const filled = max > 0 ? Math.round((value / max) * width) : 0;
|
|
15
|
+
return <code style={{ fontFamily: 'ui-monospace, monospace', color: '#0a0', whiteSpace: 'pre' }}>
|
|
16
|
+
{'#'.repeat(filled)}{'.'.repeat(Math.max(0, width - filled))}
|
|
17
|
+
</code>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function App() {
|
|
21
|
+
const [metrics, setMetrics] = useState<Metrics | null>(null);
|
|
22
|
+
const [error, setError] = useState('');
|
|
23
|
+
|
|
24
|
+
const load = async () => {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch('/api/metrics');
|
|
27
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
28
|
+
setMetrics(await res.json());
|
|
29
|
+
} catch (err) { setError((err as Error).message); }
|
|
30
|
+
};
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
load();
|
|
33
|
+
const t = setInterval(load, 5000);
|
|
34
|
+
return () => clearInterval(t);
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const hit = (path: string) => fetch(path).catch(() => {});
|
|
38
|
+
|
|
39
|
+
if (error) return <main style={{ fontFamily: 'system-ui', margin: '2rem' }}><p style={{ color: '#c00' }}>{error}</p></main>;
|
|
40
|
+
if (!metrics) return <main style={{ fontFamily: 'system-ui', margin: '2rem' }}>Loading metrics...</main>;
|
|
41
|
+
|
|
42
|
+
const maxPath = Math.max(1, ...metrics.topPaths.map((p) => p.count));
|
|
43
|
+
const maxHour = Math.max(1, ...metrics.hourly);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<main style={{ fontFamily: 'system-ui', maxWidth: 760, margin: '2rem auto', padding: '0 1rem' }}>
|
|
47
|
+
<h1>Dashboard — {metrics.date}</h1>
|
|
48
|
+
|
|
49
|
+
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem' }}>
|
|
50
|
+
<div style={{ border: '1px solid #ccc', borderRadius: 8, padding: '0.75rem 1.25rem' }}>
|
|
51
|
+
<div style={{ fontSize: '1.75rem', fontWeight: 700 }}>{metrics.requestsToday}</div>
|
|
52
|
+
<div style={{ color: '#666', fontSize: '0.85rem' }}>requests today</div>
|
|
53
|
+
</div>
|
|
54
|
+
<div style={{ border: '1px solid #ccc', borderRadius: 8, padding: '0.75rem 1.25rem' }}>
|
|
55
|
+
<div style={{ fontSize: '1.75rem', fontWeight: 700 }}>{metrics.avgLatencyMs}ms</div>
|
|
56
|
+
<div style={{ color: '#666', fontSize: '0.85rem' }}>avg latency</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div style={{ alignSelf: 'center', display: 'flex', gap: '0.5rem' }}>
|
|
59
|
+
<button onClick={() => hit('/api/ping')}>Hit /api/ping</button>
|
|
60
|
+
<button onClick={() => hit('/api/slow')}>Hit /api/slow</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<h2 style={{ fontSize: '1rem' }}>Top paths</h2>
|
|
65
|
+
{metrics.topPaths.length === 0 && <p style={{ color: '#666' }}>No traffic yet. Hit an endpoint above.</p>}
|
|
66
|
+
<table cellPadding={4} style={{ borderCollapse: 'collapse', fontFamily: 'ui-monospace, monospace', fontSize: '0.85rem' }}>
|
|
67
|
+
<tbody>
|
|
68
|
+
{metrics.topPaths.map((p) => (
|
|
69
|
+
<tr key={p.path}>
|
|
70
|
+
<td style={{ paddingRight: 12 }}>{p.path}</td>
|
|
71
|
+
<td style={{ paddingRight: 12, textAlign: 'right' }}>{p.count}</td>
|
|
72
|
+
<td style={{ paddingRight: 12, textAlign: 'right', color: '#666' }}>{p.avgMs}ms</td>
|
|
73
|
+
<td><Bar value={p.count} max={maxPath} /></td>
|
|
74
|
+
</tr>
|
|
75
|
+
))}
|
|
76
|
+
</tbody>
|
|
77
|
+
</table>
|
|
78
|
+
|
|
79
|
+
<h2 style={{ fontSize: '1rem', marginTop: '1.5rem' }}>Hourly requests</h2>
|
|
80
|
+
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 80 }}>
|
|
81
|
+
{metrics.hourly.map((v, h) => (
|
|
82
|
+
<div
|
|
83
|
+
key={h}
|
|
84
|
+
title={`${h}:00 — ${v}`}
|
|
85
|
+
style={{
|
|
86
|
+
width: 24,
|
|
87
|
+
height: Math.max(2, (v / maxHour) * 80),
|
|
88
|
+
background: h === new Date().getHours() ? '#06c' : '#9ec5fe',
|
|
89
|
+
borderRadius: '2px 2px 0 0',
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
<div style={{ fontSize: '0.7rem', color: '#999' }}>0h {'-'.repeat(30)} 23h (auto-refreshes every 5s)</div>
|
|
95
|
+
</main>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const el = document.getElementById('root');
|
|
100
|
+
if (el) createRoot(el).render(<App />);
|